From c9e31a0e6ce8ab4cf9644b626b60ef9a3b68943c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 31 Mar 2026 18:47:00 +0300 Subject: [PATCH] perf: harden sync architecture and modularize runtimes (#803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: added desktop app background throttling * perf: add streaming debug metrics panel - Show streaming performance metrics in the debug panel - Auto-enable stream profiling while the panel is open - Add JSON export for sharing UI and VS Code metrics * perf: batch streaming updates more aggressively - Buffer message deltas and metadata updates to cut render churn - Skip no-op part updates before they touch the message store - Fix the desktop debug panel shortcut binding * perf: split streaming event handling and coalesce deltas - Move streaming content events onto a dedicated fast path - Defer non-critical stream side effects off the hot path - Merge repeated message delta events before they reach the UI * perf: isolate streaming rows from chat rerenders - Memoize chat rows against render-relevant message changes only - Read live assistant text directly from store to narrow streaming updates - Split the active streaming entry from the stable message list path * perf: streamline chat streaming and SSE proxying - Reduce chat rerenders around the active streaming path - Simplify server SSE forwarding to avoid duplicate proxy work * fix: preserve the first streaming text chunk - Show the initial text chunk immediately before batched deltas arrive - Bypass batching for the first text or reasoning part update - Keep later streaming updates buffered for performance * perf: align streaming/render hot paths with opencode parity * perf: harden turn/cache stability and stale delta suppression * fix: stabilize chat rendering and disable timeline interactions - Disabled timeline dialog access from shortcuts, commands, and chat input - Reduced chat render churn by simplifying message list and turn staging behavior - Improved session-switch stability to prevent update-depth crashes * perf: track static message rerenders during streaming * perf: reduce sorted-mode activity rerender fanout * perf: reduce chat rerender fanout and add active-turn metrics - Reduced sorted-mode rerender coupling by tightening turn context propagation - Added a metric for static rerenders outside the active turn during streaming - Exposed new chat render counters in the debug panel for parity tracking * fix: keep sorted activity mounted while stream grows * fix: stabilize session and history scroll rendering * refactor: decouple server routes from index * refactor: extract fs module from server index * refactor: move opencode route ownership into module * refactor: extract notification route registration * refactor: extract opencode and notification runtimes from index * refactor: extract settings runtime and complete server modularization pass * refactor: modularize server config, skills, icons, and tunnel routes * refactor: extract server modules from monolithic index.js Split proxy, routes, runtime helpers, and notification emitter into dedicated modules under packages/web/server/lib/. * refactor: replace session/message stores with SSE-driven sync layer Delete ~9200 lines of old architecture (useEventStream, messageStore, sessionStore, useSessionStore, questionStore, useTodoStore, client SSE). New sync layer: event pipeline with coalescing + 16ms flush, pure event reducer, per-directory child stores with LRU eviction, cursor pagination, optimistic updates, deferred timeline staging, text throttle. Migrate all UI consumers to sync hooks (useSessionMessages, useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc). Strip session-ui-store to UI-only state, delegate SDK ops to session-actions with abort-if-busy, optimistic store updates, and response merging for revert/fork/archive/delete. Add notification-store for SSE-driven session attention tracking, cross-directory GlobalSessionStatusStore for sidebar indicators, client-side diff snapshot sanitization to prevent memory bloat, and revert message filtering via useVisibleSessionMessages. * feat: notification store, session actions, activity detection Add notification-store.ts for SSE-driven attention tracking. Add sanitize.ts to strip diff snapshot memory bloat. Add session-actions.ts with optimistic revert/fork/archive/delete. Improve useSessionActivity with incomplete-message fallback. Delete useServerSessionStatus polling hook. * fix: add directory param to all SDK calls, fix command/shell/abort routing All SDK calls in session-actions.ts now pass directory parameter — required by OpenCode server to scope session operations. Without it, abort, commands, revert, fork, and other operations returned 500. Add routeMessage() in session-ui-store for shell mode (session.shell), slash commands (session.command), and normal prompts. Command lookup checks both sync child store and useCommandsStore. Handle /compact locally via session.summarize(). Implement getContextUsage() to restore header context usage display — reads token counts from last assistant message in sync store. * refactor: replace custom API proxy with http-proxy-middleware Remove ~280 lines of custom proxy code: forwardSseRequest, forwardGenericApiRequest, collectRequestBodyBuffer, header manipulation, hop-by-hop filtering, SSE block buffering. Replace with single createProxyMiddleware() call that handles SSE streaming, large bodies, and timeouts out of the box. Dynamic router for OpenCode port changes after restarts. Auth headers injected via proxyReq hook. Keep: readiness gate, Windows session merge, API prefix detection. * perf: targeted event draft cloning to fix streaming render cascade Event handler was eagerly cloning all state slices on every event, breaking Zustand selector referential equality. During streaming (~60 events/sec), this caused every subscriber to re-render regardless of which slice actually changed. Now only clones fields the specific event type mutates. Also extracts StatusRowContainer to isolate high-frequency useAssistantStatus subscription, removes dead messageStreamStatesMap subscription from ChatContainer, and narrows useAssistantStatus to only track last assistant message parts. MessageList renders: 1972 → 296 per streaming session (-85%). * fix: null safety for sync state slices Add defensive ?? {} guards on permission, question, session_status, and message record access. Prevents crashes when child store state is partially initialized during bootstrap. * perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK calls. Delay initial PR background tracking by 5s to reduce startup CPU burst. * fix: header session lookup across all child stores Session title and context panel click failed when session belonged to a different directory than the current child store. Fall back to getAllSyncSessions() to search all initialized stores. * chore: bump @opencode-ai/sdk to 1.3.5 * docs: add sync event handling guide * Optimize session prefetch and improve delete/archive UX - Add settlement delay to session prefetch to avoid race conditions on rapid session switches - Reduce git diff prefetch and session cache limits for better performance - Implement optimistic UI updates for session delete/archive operations with proper rollback on failure - Wire session prefetch hook into SessionSidebar with sync integration * Add file content cache and sync optimizations - Wrap FilesAPI with in-memory LRU cache for file content with dual constraints (entry count and byte size) - Optimize chat timeline scroll restoration using useLayoutEffect - Preserve React references in message and part arrays to prevent unnecessary re-renders when prepending history - Add session prefetch TTL cache to prevent redundant fetches - Integrate session prefetch cache clearing with eviction flow * Improve session sidebar error handling and add diff prefetch filtering Load active and archived sessions independently using Promise.allSettled to prevent one failure from blocking the other. Add retry logic to session API calls and skip large files during diff prefetch to improve performance. * Replace sendMessage with optimisticSend wrapper Introduces optimistic UI updates for normal chat messages to provide instant feedback. Messages appear immediately in the UI while the API call executes in the background, with automatic rollback on errors. * perf: split stores, proper optimistic send, fix revert/directory bugs - split session-ui-store into voice/input/selection/viewport stores to reduce subscriber re-evaluation during streaming - wire optimisticSend through useSync shadow Map infrastructure matching OpenCode's pattern (no heuristic part detection) - port OpenCode Identifier.ascending ID format for correct sorting - pass messageID to promptAsync to prevent duplicate messages - fix worktree directory not propagating to session actions (dynamic dir() via opencodeClient.getDirectory) - fix setCurrentSession accepting directoryHint for new sessions - fix revert not hiding messages (session limit was 5, bumped to match loaded count) - fix revert optimistic message removal from store - fix load-more flicker (useLayoutEffect scroll compensation) - add prefetch TTL cache, file content LRU cache - add session prefetch for adjacent sessions - add instant archive/delete (optimistic before SDK call) - migrate legacy window.__zustand_session_store__ to session-ui-store - add retry + independent error handling for archived sessions - add AGENTS.md performance rules * perf: startup optimization — dedup, caching, light git status, diff rendering gates - defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines - cap project git checks concurrency (2), directory status probe (3) - dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches) - delay PR tracking 5s, cache 403 search failures per-repo - coalesce settings PUT (200ms debounce), cache settings GET (2s TTL) - cache canonical directory resolution (60s TTL) - persist missing directory status to localStorage (10min TTL) - light/heavy git status: polling skips numstat+line counting+rev-list - large diff rendering gate (>500 lines → "render anyway" button) - tokenization degradation for >500KB files in Pierre - parallelize main.tsx pre-render awaits - batch sidebar file tree expanded paths restoration (3 at a time) - remove bare useConfigStore() subscription in AgentsPage - sync worktree sandboxes to OpenCode SQLite DB - fix RightSidebarTabs ternary → explicit tab matching - defensive guards on sync state (session_status, permission, question, message) * fix: add defensive guards on remaining sync state field accesses guard session_status, permission, message, todo, part, config with ?? {} in useDirectorySync selectors, session-cache, and bootstrap * fix: add missing directory dep to useCallback in use-sync.ts * fix: preserve diffStats when light-mode polling overwrites status * perf: optimize startup git status polling and diff rendering Preserves diff stats when lightweight polling updates repository status Reduces startup overhead with smarter git polling and store updates Adds detailed optimization and migration docs for next performance steps * fix: keep chat diff stats stable during git status updates Prevents lightweight git polling from dropping diff statistics Keeps MessageList diff indicators consistent while status refreshes Improves reliability of git-aware chat rendering * fix: user animation replay, queued message variant, startup provider loading - consume animation ID after first play to prevent re-animation on neighbor assistant message completion - capture send config (model/agent/variant) at queue time matching OpenCode's FollowupDraft pattern instead of re-resolving at send time - replace one-shot startup recovery effect with polling interval that retries every 2s until providers and agents load - fix optimistic bridge to avoid re-render loop (stable ref wrappers) * chore: update tauri to 2.10.3 and all plugins to latest - tauri 2.9.4 → 2.10.3 - tauri-build 2.5.3 → 2.5.6 - tauri-plugin-dialog 2.4.2 → 2.6.0 - tauri-plugin-log 2.7.1 → 2.8.0 - tauri-plugin-shell 2.3.3 → 2.3.5 - tauri-plugin-updater 2 (floating) → 2.10.0 (pinned) - @tauri-apps/api ^2.9.0 → ^2.10.1 - wry 0.53.5 → 0.54.4 (transitive) * refactor: decouple web server index orchestration runtimes * fix: align VS Code runtime behavior with web and reduce draft view CPU load - Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures - Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling - Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage * fix: restore auto-selected file sending in chat input - Send server-selected files as proper file URLs in the message payload - Include server-backed attachments in submit flow instead of dropping them - Restore queued-message attachments through the refactored input store * fix: restore session model selection consistently on session switch - Restore agent, model, and variant from the latest loaded user message for each session - Wait for session messages before applying restored selections to avoid stale or missing state - Remove legacy session-choice inference paths that caused overlap and instability * fix: restore permission replies and auto-accept across sessions - Scope permission and question replies to the target session directory so answers take effect reliably - Make permission auto-accept immediately handle pending requests and react to new permission prompts - Keep parent-session handling working for child-session requests through the shared response path * feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798) * feat: add reusable fuzzy branch search for worktrees * chore: drop planning docs from feature branch * feat: make worktree branch refresh manual * feat: add configurable session retention action * refactor: centralize global session state in ui store * fix: cancel debounced permission push after reply * docs: clarify global and directory session store architecture * docs: refine agent development rules and session activity guidance - Clarify agent code of conduct and durable development patterns - Add explicit shared-store rerender and live-state guidance - Narrow session activity fallback to avoid stale working state * chore: updated .gitignore --------- Co-authored-by: Iuliia Ivashko --- .gitignore | 1 + AGENTS.md | 152 +- bun.lock | 12 +- package.json | 2 +- packages/desktop/src-tauri/Cargo.lock | 1985 ++- packages/desktop/src-tauri/Cargo.toml | 12 +- packages/desktop/src-tauri/src/main.rs | 9 +- packages/desktop/src-tauri/tauri.conf.json | 3 +- packages/ui/package.json | 4 +- packages/ui/src/App.tsx | 315 +- .../ui/src/components/chat/ChatContainer.tsx | 243 +- packages/ui/src/components/chat/ChatInput.tsx | 153 +- .../ui/src/components/chat/ChatMessage.tsx | 90 +- .../components/chat/CommandAutocomplete.tsx | 23 +- .../ui/src/components/chat/FileAttachment.tsx | 7 +- .../ui/src/components/chat/MessageList.tsx | 759 +- .../src/components/chat/MobileAgentButton.tsx | 7 +- .../chat/MobileSessionStatusBar.tsx | 69 +- .../ui/src/components/chat/ModelControls.tsx | 365 +- .../ui/src/components/chat/PermissionCard.tsx | 21 +- .../src/components/chat/PermissionRequest.tsx | 4 +- .../ui/src/components/chat/QuestionCard.tsx | 22 +- .../components/chat/QueuedMessageChips.tsx | 11 +- .../ui/src/components/chat/StatusChip.tsx | 4 +- packages/ui/src/components/chat/StatusRow.tsx | 30 +- .../components/chat/StatusRowContainer.tsx | 30 + .../ui/src/components/chat/TimelineDialog.tsx | 16 +- .../components/chat/UnifiedControlsDrawer.tsx | 10 +- .../components/chat/components/TurnList.tsx | 13 +- .../chat/hooks/useChatTimelineController.ts | 104 +- .../components/chat/hooks/useTurnRecords.ts | 56 +- .../chat/lib/turns/historySignals.ts | 2 +- .../chat/lib/turns/projectTurnActivity.ts | 5 - .../components/chat/lib/turns/stageTurns.ts | 4 +- .../ui/src/components/chat/lib/turns/types.ts | 1 + .../components/chat/message/MessageBody.tsx | 64 +- .../chat/message/TextSelectionMenu.tsx | 7 +- .../chat/message/parts/AssistantTextPart.tsx | 38 +- .../chat/message/parts/ReasoningPart.tsx | 10 +- .../chat/message/parts/ToolPart.tsx | 32 +- .../components/chat/message/renderCompare.ts | 115 + .../comments/useInlineCommentController.ts | 6 +- .../components/layout/ContextSidebarTab.tsx | 14 +- packages/ui/src/components/layout/Header.tsx | 36 +- .../components/layout/RightSidebarTabs.tsx | 3 +- .../layout/SidebarContextSummary.tsx | 7 +- .../components/layout/SidebarFilesTree.tsx | 30 +- .../ui/src/components/layout/VSCodeLayout.tsx | 81 +- .../components/multirun/MultiRunLauncher.tsx | 4 +- .../components/sections/agents/AgentsPage.tsx | 8 +- .../openchamber/SessionRetentionSettings.tsx | 52 +- .../openchamber/WorktreeSectionContent.tsx | 6 +- .../components/session/BranchPickerDialog.tsx | 4 +- .../session/GitHubIssuePickerDialog.tsx | 13 +- .../components/session/NewWorktreeDialog.tsx | 637 +- .../session/ProjectNotesTodoPanel.tsx | 9 +- .../src/components/session/SessionDialogs.tsx | 55 +- .../src/components/session/SessionSidebar.tsx | 149 +- .../session/sidebar/SessionNodeItem.tsx | 7 +- .../sidebar/hooks/useDirectoryStatusProbe.ts | 173 +- .../sidebar/hooks/useProjectRepoStatus.ts | 50 +- .../sidebar/hooks/useSessionPrefetch.ts | 53 +- .../ui/src/components/ui/CommandPalette.tsx | 57 +- packages/ui/src/components/ui/HelpDialog.tsx | 6 - .../ui/src/components/ui/MemoryDebugPanel.tsx | 450 +- .../ui/src/components/ui/OpenChamberLogo.tsx | 29 +- packages/ui/src/components/views/ChatView.tsx | 4 +- packages/ui/src/components/views/DiffView.tsx | 28 +- packages/ui/src/components/views/GitView.tsx | 32 +- .../src/components/views/PierreDiffViewer.tsx | 15 +- packages/ui/src/components/views/PlanView.tsx | 7 +- .../ui/src/components/views/TerminalView.tsx | 5 +- .../views/agent-manager/AgentGroupDetail.tsx | 128 +- .../agent-manager/AgentManagerSidebar.tsx | 83 +- .../views/agent-manager/AgentManagerView.tsx | 127 +- .../components/views/git/ConflictDialog.tsx | 11 +- .../views/git/IntegrateCommitsSection.tsx | 13 +- .../views/git/PullRequestSection.tsx | 13 +- .../ui/src/contexts/RuntimeAPIProvider.tsx | 81 +- packages/ui/src/hooks/useAssistantStatus.ts | 104 +- packages/ui/src/hooks/useBrowserVoice.ts | 47 +- packages/ui/src/hooks/useChatScrollManager.ts | 29 +- .../ui/src/hooks/useChatSearchDirectory.ts | 11 +- .../ui/src/hooks/useEffectiveDirectory.ts | 19 +- packages/ui/src/hooks/useEventStream.ts | 2676 --- .../hooks/useGitHubPrBackgroundTracking.ts | 52 +- packages/ui/src/hooks/useGitPollingHook.ts | 22 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 56 +- packages/ui/src/hooks/useMenuActions.ts | 20 +- packages/ui/src/hooks/usePwaManifestSync.ts | 7 +- .../ui/src/hooks/useQueuedMessageAutoSend.ts | 58 +- packages/ui/src/hooks/useRouter.ts | 18 +- .../ui/src/hooks/useServerSessionStatus.ts | 334 - packages/ui/src/hooks/useSessionActivity.ts | 66 +- .../ui/src/hooks/useSessionAutoCleanup.ts | 89 +- .../ui/src/hooks/useSessionStatusBootstrap.ts | 53 +- packages/ui/src/hooks/useTimelineStaging.ts | 114 + packages/ui/src/hooks/useVoiceContext.ts | 37 +- packages/ui/src/lib/api/types.ts | 3 +- packages/ui/src/lib/appearanceAutoSave.ts | 6 + packages/ui/src/lib/concurrency.ts | 27 + packages/ui/src/lib/debug.ts | 107 +- packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/gitApi.ts | 8 +- packages/ui/src/lib/gitApiHttp.ts | 7 +- packages/ui/src/lib/openCodeStatus.ts | 8 +- packages/ui/src/lib/opencode/client.ts | 681 +- packages/ui/src/lib/persistence.ts | 104 +- packages/ui/src/lib/search/fuzzySearch.ts | 129 + packages/ui/src/lib/shortcuts.ts | 7 - packages/ui/src/lib/utils.ts | 17 +- .../ui/src/lib/voice/realtimeClientTools.ts | 15 +- packages/ui/src/lib/worktreeSessionCreator.ts | 25 +- packages/ui/src/lib/worktrees/branchSearch.ts | 67 + .../ui/src/lib/worktrees/worktreeManager.ts | 72 +- packages/ui/src/main.tsx | 59 +- packages/ui/src/stores/contextStore.ts | 167 - packages/ui/src/stores/globalSessions.ts | 14 +- packages/ui/src/stores/messageQueueStore.ts | 8 + packages/ui/src/stores/messageStore.ts | 3006 ---- packages/ui/src/stores/permissionStore.ts | 255 +- packages/ui/src/stores/questionStore.ts | 123 - packages/ui/src/stores/sessionStore.ts | 1750 -- packages/ui/src/stores/types/sessionTypes.ts | 14 +- packages/ui/src/stores/useAgentGroupsStore.ts | 951 +- packages/ui/src/stores/useConfigStore.ts | 79 +- packages/ui/src/stores/useGitHubAuthStore.ts | 35 +- packages/ui/src/stores/useGitStore.ts | 67 +- .../ui/src/stores/useGlobalSessionsStore.ts | 308 + packages/ui/src/stores/useMultiRunStore.ts | 12 +- packages/ui/src/stores/useSessionStore.ts | 1484 -- packages/ui/src/stores/useTodoStore.ts | 98 - packages/ui/src/stores/useUIStore.ts | 9 + packages/ui/src/stores/utils/messageUtils.ts | 70 + packages/ui/src/stores/utils/streamDebug.ts | 238 + packages/ui/src/sync/DOCUMENTATION.md | 217 + packages/ui/src/sync/binary.ts | 46 + packages/ui/src/sync/bootstrap.ts | 161 + packages/ui/src/sync/child-store.ts | 172 + packages/ui/src/sync/content-cache.ts | 93 + packages/ui/src/sync/event-pipeline.ts | 221 + packages/ui/src/sync/event-reducer.ts | 345 + packages/ui/src/sync/eviction.ts | 28 + packages/ui/src/sync/global-sync-store.ts | 27 + packages/ui/src/sync/index.ts | 152 + packages/ui/src/sync/input-store.ts | 79 + packages/ui/src/sync/notification-store.ts | 170 + packages/ui/src/sync/optimistic.ts | 127 + packages/ui/src/sync/persist-cache.ts | 116 + packages/ui/src/sync/retry.ts | 54 + packages/ui/src/sync/sanitize.ts | 67 + packages/ui/src/sync/selection-store.ts | 86 + packages/ui/src/sync/session-actions.ts | 586 + packages/ui/src/sync/session-cache.ts | 61 + .../ui/src/sync/session-prefetch-cache.ts | 113 + packages/ui/src/sync/session-ui-store.ts | 1143 ++ packages/ui/src/sync/streaming.ts | 131 + packages/ui/src/sync/submit.ts | 143 + packages/ui/src/sync/sync-context.tsx | 639 + packages/ui/src/sync/sync-refs.ts | 92 + packages/ui/src/sync/types.ts | 142 + packages/ui/src/sync/use-sync.ts | 413 + packages/ui/src/sync/viewport-store.ts | 49 + packages/ui/src/sync/voice-store.ts | 23 + packages/vscode/package.json | 2 +- packages/vscode/src/DOCUMENTATION.md | 61 + packages/vscode/src/bridge-config-runtime.ts | 595 + .../vscode/src/bridge-fs-helpers-runtime.ts | 563 + packages/vscode/src/bridge-fs-runtime.ts | 419 + .../vscode/src/bridge-git-process-runtime.ts | 105 + packages/vscode/src/bridge-git-runtime.ts | 422 + .../vscode/src/bridge-git-special-runtime.ts | 455 + .../src/bridge-localfs-proxy-runtime.ts | 99 + packages/vscode/src/bridge-proxy-runtime.ts | 180 + .../vscode/src/bridge-settings-runtime.ts | 231 + packages/vscode/src/bridge-system-runtime.ts | 558 + packages/vscode/src/bridge.ts | 3893 +--- packages/vscode/src/opencode-ready.ts | 59 + packages/vscode/src/sessionActivityWatcher.ts | 8 + packages/vscode/src/sseProxy.ts | 7 +- packages/vscode/webview/api/streamPerf.ts | 88 + packages/vscode/webview/main.tsx | 187 +- packages/web/package.json | 2 +- packages/web/server/index.js | 14887 +--------------- packages/web/server/lib/fs/DOCUMENTATION.md | 36 + packages/web/server/lib/fs/routes.js | 760 + packages/web/server/lib/fs/search.js | 238 + packages/web/server/lib/git/DOCUMENTATION.md | 1 + packages/web/server/lib/git/routes.js | 867 + packages/web/server/lib/git/service.js | 43 +- .../web/server/lib/github/DOCUMENTATION.md | 1 + packages/web/server/lib/github/pr-status.js | 21 +- packages/web/server/lib/github/routes.js | 1349 ++ .../server/lib/notifications/DOCUMENTATION.md | 67 + .../lib/notifications/emitter-runtime.js | 65 + .../web/server/lib/notifications/index.js | 3 + .../server/lib/notifications/push-runtime.js | 294 + .../web/server/lib/notifications/routes.js | 247 + .../web/server/lib/notifications/runtime.js | 471 + .../lib/notifications/template-runtime.js | 436 + .../web/server/lib/opencode/DOCUMENTATION.md | 298 +- .../server/lib/opencode/auth-state-runtime.js | 88 + .../server/lib/opencode/bootstrap-runtime.js | 119 + .../server/lib/opencode/cli-entry-runtime.js | 43 + .../web/server/lib/opencode/cli-options.js | 128 + .../lib/opencode/config-entity-routes.js | 362 + .../web/server/lib/opencode/core-routes.js | 186 + .../web/server/lib/opencode/env-config.js | 72 + .../web/server/lib/opencode/env-runtime.js | 908 + .../lib/opencode/feature-routes-runtime.js | 216 + .../server/lib/opencode/hmr-state-runtime.js | 85 + packages/web/server/lib/opencode/lifecycle.js | 630 + .../server/lib/opencode/network-runtime.js | 98 + .../server/lib/opencode/openchamber-routes.js | 284 + .../opencode/opencode-resolution-runtime.js | 67 + .../lib/opencode/project-directory-runtime.js | 109 + .../lib/opencode/project-icon-routes.js | 397 + packages/web/server/lib/opencode/proxy.js | 169 + .../lib/opencode/pwa-manifest-routes.js | 238 + packages/web/server/lib/opencode/routes.js | 206 + .../lib/opencode/server-startup-runtime.js | 138 + .../lib/opencode/server-utils-runtime.js | 168 + .../server/lib/opencode/session-runtime.js | 320 + .../server/lib/opencode/settings-helpers.js | 602 + .../settings-normalization-runtime.js | 428 + .../server/lib/opencode/settings-runtime.js | 439 + .../server/lib/opencode/shutdown-runtime.js | 114 + .../web/server/lib/opencode/skill-routes.js | 707 + .../lib/opencode/startup-pipeline-runtime.js | 107 + .../lib/opencode/static-routes-runtime.js | 63 + .../web/server/lib/opencode/theme-runtime.js | 167 + .../lib/opencode/tunnel-wiring-runtime.js | 94 + packages/web/server/lib/opencode/watcher.js | 107 + .../web/server/lib/quota/DOCUMENTATION.md | 1 + packages/web/server/lib/quota/routes.js | 27 + .../server/lib/security/request-security.js | 115 + .../web/server/lib/terminal/DOCUMENTATION.md | 1 + packages/web/server/lib/terminal/runtime.js | 708 + packages/web/server/lib/tts/DOCUMENTATION.md | 5 + .../web/server/lib/tts/capability-runtime.js | 31 + packages/web/server/lib/tts/routes.js | 225 + .../web/server/lib/tunnels/DOCUMENTATION.md | 18 + .../web/server/lib/tunnels/managed-config.js | 201 + packages/web/server/lib/tunnels/routes.js | 605 + scripts/dev-web-hmr.mjs | 20 +- 245 files changed, 31986 insertions(+), 32683 deletions(-) create mode 100644 packages/ui/src/components/chat/StatusRowContainer.tsx create mode 100644 packages/ui/src/components/chat/message/renderCompare.ts delete mode 100644 packages/ui/src/hooks/useEventStream.ts delete mode 100644 packages/ui/src/hooks/useServerSessionStatus.ts create mode 100644 packages/ui/src/hooks/useTimelineStaging.ts create mode 100644 packages/ui/src/lib/concurrency.ts create mode 100644 packages/ui/src/lib/search/fuzzySearch.ts create mode 100644 packages/ui/src/lib/worktrees/branchSearch.ts delete mode 100644 packages/ui/src/stores/messageStore.ts delete mode 100644 packages/ui/src/stores/questionStore.ts delete mode 100644 packages/ui/src/stores/sessionStore.ts create mode 100644 packages/ui/src/stores/useGlobalSessionsStore.ts delete mode 100644 packages/ui/src/stores/useSessionStore.ts delete mode 100644 packages/ui/src/stores/useTodoStore.ts create mode 100644 packages/ui/src/sync/DOCUMENTATION.md create mode 100644 packages/ui/src/sync/binary.ts create mode 100644 packages/ui/src/sync/bootstrap.ts create mode 100644 packages/ui/src/sync/child-store.ts create mode 100644 packages/ui/src/sync/content-cache.ts create mode 100644 packages/ui/src/sync/event-pipeline.ts create mode 100644 packages/ui/src/sync/event-reducer.ts create mode 100644 packages/ui/src/sync/eviction.ts create mode 100644 packages/ui/src/sync/global-sync-store.ts create mode 100644 packages/ui/src/sync/index.ts create mode 100644 packages/ui/src/sync/input-store.ts create mode 100644 packages/ui/src/sync/notification-store.ts create mode 100644 packages/ui/src/sync/optimistic.ts create mode 100644 packages/ui/src/sync/persist-cache.ts create mode 100644 packages/ui/src/sync/retry.ts create mode 100644 packages/ui/src/sync/sanitize.ts create mode 100644 packages/ui/src/sync/selection-store.ts create mode 100644 packages/ui/src/sync/session-actions.ts create mode 100644 packages/ui/src/sync/session-cache.ts create mode 100644 packages/ui/src/sync/session-prefetch-cache.ts create mode 100644 packages/ui/src/sync/session-ui-store.ts create mode 100644 packages/ui/src/sync/streaming.ts create mode 100644 packages/ui/src/sync/submit.ts create mode 100644 packages/ui/src/sync/sync-context.tsx create mode 100644 packages/ui/src/sync/sync-refs.ts create mode 100644 packages/ui/src/sync/types.ts create mode 100644 packages/ui/src/sync/use-sync.ts create mode 100644 packages/ui/src/sync/viewport-store.ts create mode 100644 packages/ui/src/sync/voice-store.ts create mode 100644 packages/vscode/src/DOCUMENTATION.md create mode 100644 packages/vscode/src/bridge-config-runtime.ts create mode 100644 packages/vscode/src/bridge-fs-helpers-runtime.ts create mode 100644 packages/vscode/src/bridge-fs-runtime.ts create mode 100644 packages/vscode/src/bridge-git-process-runtime.ts create mode 100644 packages/vscode/src/bridge-git-runtime.ts create mode 100644 packages/vscode/src/bridge-git-special-runtime.ts create mode 100644 packages/vscode/src/bridge-localfs-proxy-runtime.ts create mode 100644 packages/vscode/src/bridge-proxy-runtime.ts create mode 100644 packages/vscode/src/bridge-settings-runtime.ts create mode 100644 packages/vscode/src/bridge-system-runtime.ts create mode 100644 packages/vscode/src/opencode-ready.ts create mode 100644 packages/vscode/webview/api/streamPerf.ts create mode 100644 packages/web/server/lib/fs/DOCUMENTATION.md create mode 100644 packages/web/server/lib/fs/routes.js create mode 100644 packages/web/server/lib/fs/search.js create mode 100644 packages/web/server/lib/git/routes.js create mode 100644 packages/web/server/lib/github/routes.js create mode 100644 packages/web/server/lib/notifications/emitter-runtime.js create mode 100644 packages/web/server/lib/notifications/push-runtime.js create mode 100644 packages/web/server/lib/notifications/routes.js create mode 100644 packages/web/server/lib/notifications/runtime.js create mode 100644 packages/web/server/lib/notifications/template-runtime.js create mode 100644 packages/web/server/lib/opencode/auth-state-runtime.js create mode 100644 packages/web/server/lib/opencode/bootstrap-runtime.js create mode 100644 packages/web/server/lib/opencode/cli-entry-runtime.js create mode 100644 packages/web/server/lib/opencode/cli-options.js create mode 100644 packages/web/server/lib/opencode/config-entity-routes.js create mode 100644 packages/web/server/lib/opencode/core-routes.js create mode 100644 packages/web/server/lib/opencode/env-config.js create mode 100644 packages/web/server/lib/opencode/env-runtime.js create mode 100644 packages/web/server/lib/opencode/feature-routes-runtime.js create mode 100644 packages/web/server/lib/opencode/hmr-state-runtime.js create mode 100644 packages/web/server/lib/opencode/lifecycle.js create mode 100644 packages/web/server/lib/opencode/network-runtime.js create mode 100644 packages/web/server/lib/opencode/openchamber-routes.js create mode 100644 packages/web/server/lib/opencode/opencode-resolution-runtime.js create mode 100644 packages/web/server/lib/opencode/project-directory-runtime.js create mode 100644 packages/web/server/lib/opencode/project-icon-routes.js create mode 100644 packages/web/server/lib/opencode/proxy.js create mode 100644 packages/web/server/lib/opencode/pwa-manifest-routes.js create mode 100644 packages/web/server/lib/opencode/routes.js create mode 100644 packages/web/server/lib/opencode/server-startup-runtime.js create mode 100644 packages/web/server/lib/opencode/server-utils-runtime.js create mode 100644 packages/web/server/lib/opencode/session-runtime.js create mode 100644 packages/web/server/lib/opencode/settings-helpers.js create mode 100644 packages/web/server/lib/opencode/settings-normalization-runtime.js create mode 100644 packages/web/server/lib/opencode/settings-runtime.js create mode 100644 packages/web/server/lib/opencode/shutdown-runtime.js create mode 100644 packages/web/server/lib/opencode/skill-routes.js create mode 100644 packages/web/server/lib/opencode/startup-pipeline-runtime.js create mode 100644 packages/web/server/lib/opencode/static-routes-runtime.js create mode 100644 packages/web/server/lib/opencode/theme-runtime.js create mode 100644 packages/web/server/lib/opencode/tunnel-wiring-runtime.js create mode 100644 packages/web/server/lib/opencode/watcher.js create mode 100644 packages/web/server/lib/quota/routes.js create mode 100644 packages/web/server/lib/security/request-security.js create mode 100644 packages/web/server/lib/terminal/runtime.js create mode 100644 packages/web/server/lib/tts/capability-runtime.js create mode 100644 packages/web/server/lib/tts/routes.js create mode 100644 packages/web/server/lib/tunnels/DOCUMENTATION.md create mode 100644 packages/web/server/lib/tunnels/managed-config.js create mode 100644 packages/web/server/lib/tunnels/routes.js diff --git a/.gitignore b/.gitignore index 0565d35a..db169364 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ local-dev* *.sw? .opencode/plans/* .hive +docs/personal/* # Build outputs build/ diff --git a/AGENTS.md b/AGENTS.md index c601cacb..dc72473e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,18 +107,56 @@ All scripts are in `package.json`. - Do not run git/GitHub commands unless explicitly asked. - Keep baseline green (run `bun run type-check`, `bun run lint`, `bun run build` before finalizing changes). +## Agent code of conduct +- Prefer the smallest correct change. +- Preserve working behavior before improving structure. +- Do not add cleverness where a direct implementation is enough. +- Do not infer critical state from weak signals when a stronger source exists. +- Do not encode policy only in UI; enforce it in core logic. +- Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code. +- Finish work end-to-end: implementation, verification, and cleanup. + ## Development rules - Keep diffs tight; avoid drive-by refactors. -- Backend changes: keep web/desktop/vscode runtimes consistent (if relevant). -- Follow local precedent; search nearby code first. -- TypeScript: avoid `any`/blind casts; keep ESLint/TS green. -- React: prefer function components + hooks; class only when needed (e.g. error boundaries). -- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`. -- Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`. -- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components. -- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code. +- Follow local precedent; inspect nearby code before introducing new patterns. +- Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts. +- TypeScript: avoid `any`, blind casts, and shape guessing. +- React: prefer function components + hooks; use classes only when required. +- Control flow: prefer early returns and explicit branching over nested ternaries. +- Styling: Tailwind v4, typography via `packages/ui/src/lib/typography.ts`, theme vars via `packages/ui/src/lib/theme/`. +- Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns. +- Toasts: use the wrapper from `@/components/ui`; do not import `sonner` directly in feature code. - No new deps unless asked. -- Never add secrets (`.env`, keys) or log sensitive data. +- Never add secrets or log sensitive data. + +## Architecture patterns + +### Thin entrypoints, focused modules +- Keep orchestration entrypoints thin: `index.js`, bridge files, bootstrap files, provider roots. +- Move route, domain, and runtime logic into focused modules with clear ownership. +- Prefer dependency injection over hidden module coupling. +- Add or update module documentation when ownership changes. + +### Strong source of truth +- Prefer deterministic state over heuristics. +- Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution. +- If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary. +- Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses. + +### Live state vs historical state +- Derive live UI behavior from live state channels, not persisted history. +- Use historical records to restore context, not to infer that work is still in progress. +- If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives. + +### Cross-runtime parity +- If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable. +- Shared behavior differences must be intentional and visible in code. +- Do not ship a web-only assumption into shared UI. + +### Partial-failure-safe flows +- Cross-directory and multi-entity operations must tolerate partial failure. +- Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions. +- Never leave optimistic state or local caches stranded after failure. ## CLI Parity and Safety Policy (MANDATORY) @@ -175,6 +213,102 @@ skill({ name: "theme-system" }) This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes. +## Performance rules (MANDATORY) + +These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work. + +### Shared-store render discipline + +- **Treat common stores as render fanout boundaries.** An unnecessary reference change in shared state can re-render large parts of the app. +- **Do not put high-frequency state in broadly consumed stores.** Fast-changing state should live in narrow stores with narrow subscribers. +- **Update only the fields that changed.** Preserve references for untouched state branches. +- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component. +- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child. + +### Zustand referential equality + +Zustand skips re-renders when a selector returns the same reference (`Object.is`). Every new object/array reference triggers a re-render in every subscriber. + +- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc. +- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions. +- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added. + +### Store splitting + +A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set. + +- **Group state by how often it changes.** Streaming state (updated 60/sec) must not live with user preferences (updated on click). +- **Group state by who reads it.** If only 2 components need a value, it belongs in a store that only those 2 subscribe to. +- **Cross-store reads use `.getState()`.** Actions in one store that need another store call `useOtherStore.getState()` — imperative, no subscription. +- **Never add unrelated state to an existing store** just because it's convenient. Create a new store. + +### Event pipeline and SSE + +- **Gate expensive operations on the hot path.** During streaming, `message.part.delta` and `message.part.updated` fire ~60/sec. Any `findIndex`, `filter`, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check `next[0]` before scanning the array). +- **Skip no-op updates.** If an incoming event doesn't change the state (same role, same finish, same timestamps), return `false` from the reducer to avoid creating new references. +- **Coalesce by key.** Same-entity events (e.g., repeated `session.status` for the same session) should replace earlier ones in the queue, not accumulate. +- **Preserve event ordering semantics.** Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state. +- **Do not widen live-activity fallbacks.** A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records. + +### Polling payload fidelity + +- **Do not let lightweight polling erase rich fields.** If light mode omits fields (e.g., `diffStats`), preserve previous rich data until a heavy follow-up fetch lands. +- **Use two-phase polling.** Run cheap change detection first; only run heavy status fetches for directories that actually changed. + +### Optimistic updates + +- **Use the shadow Map pattern.** Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via `mergeOptimisticPage` on the next data fetch — not via heuristics in the event reducer. +- **Pass client-generated IDs to the server.** Use the same ID format as the server (hex-encoded timestamps). Pass `messageID` to `promptAsync` so the server echoes back the same ID. This prevents duplicates and enables in-place replacement. +- **Rollback on error.** Remove the optimistic entry from both the store and the shadow Map. +- **Stabilize bridge callbacks.** When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities. + +### Session/input consistency + +- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time. +- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending. + +### Bootstrap resilience + +- **Treat startup 502/503 as transient.** Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup. +- **Use polling recovery when failures are swallowed.** If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks. + +### Scroll and DOM + +- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints. +- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output. + +### Component isolation + +- **Extract high-frequency hook consumers into separate components.** If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a `React.memo` child component so the parent doesn't re-render. +- **Use custom `React.memo` comparators for message rows.** Compare render-relevant fields (role, finish, parts count, part IDs) — not object references. + +### Caching and memory + +- **Cap in-memory caches with both count and byte limits.** Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB). +- **Set store session limits to match loaded data.** If bootstrap loads N sessions, set `limit >= N`. Otherwise the next SSE event triggers trimming that silently removes sessions. +- **Invalidate caches on mutations.** File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction. +- **Use TTLs to prevent redundant fetches.** If a session was fetched <15s ago, skip re-fetching — SSE events keep it current. + +### Directory context + +- **Never cache directory strings in closures.** Directory can change at any time (worktree switch). Read it dynamically from `opencodeClient.getDirectory()` at call time. +- **Pass directory hints when the source of truth isn't available yet.** Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup. + +## Regression-prevention checklist +- When adding fallback logic, ask: can stale persisted data keep this path active forever? +- When deriving UI state, ask: is this live state, historical state, or inferred state? +- When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere? +- When touching polling or bootstrap, ask: can a lighter payload erase richer existing data? +- When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention? +- When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code? +- When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it. + +## Validation expectations +- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing. +- For hot-path changes, verify behavior under streaming or repeated events, not just static render. +- For sync or startup changes, verify fresh load, retry/failure, and restart behavior. +- For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant. + ## Recent changes - Releases + high-level changes: `CHANGELOG.md` - Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`) diff --git a/bun.lock b/bun.lock index daaa0f7d..57b26507 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -133,7 +133,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -179,7 +179,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.9.0", + "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", @@ -208,7 +208,7 @@ "version": "1.9.1", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -240,7 +240,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -817,7 +817,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.7", "", {}, "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], diff --git a/package.json b/package.json index 24e9b710..aebff413 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 97ff0927..044061a7 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -71,9 +71,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -90,27 +90,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -137,9 +116,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -169,9 +148,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -204,7 +183,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -239,7 +218,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -289,6 +268,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -297,9 +291,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -325,22 +319,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2", ] [[package]] @@ -358,25 +343,26 @@ dependencies = [ [[package]] name = "borsh" -version = "1.5.7" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.5.7" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -402,17 +388,18 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byte-unit" -version = "5.1.6" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" dependencies = [ "rust_decimal", + "schemars 1.2.1", "serde", "utf8-width", ] @@ -441,9 +428,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -453,9 +440,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -466,7 +453,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -487,9 +474,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -514,7 +501,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -524,14 +511,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.46" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -578,9 +565,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -641,11 +628,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -658,7 +645,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "libc", ] @@ -723,6 +710,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -730,7 +730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -740,14 +740,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -755,34 +755,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -796,7 +795,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -809,7 +808,28 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.110", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", ] [[package]] @@ -843,22 +863,16 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "libc", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -869,23 +883,14 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.9", + "syn 2.0.117", ] [[package]] name = "dlopen2" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ "dlopen2_derive", "libc", @@ -895,20 +900,29 @@ dependencies = [ [[package]] name = "dlopen2_derive" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] [[package]] name = "dpi" @@ -921,9 +935,9 @@ dependencies = [ [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" @@ -948,14 +962,14 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg", ] @@ -977,9 +991,9 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" @@ -999,7 +1013,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1020,9 +1034,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -1096,27 +1110,26 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1128,6 +1141,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1146,7 +1171,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1182,9 +1207,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1192,15 +1217,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1209,9 +1234,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1228,32 +1253,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1262,7 +1287,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1397,9 +1421,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1417,11 +1441,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gio" version = "0.18.4" @@ -1460,7 +1497,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1488,7 +1525,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1567,7 +1604,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1581,9 +1618,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1617,18 +1663,27 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", - "markup5ever", + "markup5ever 0.14.1", "match_token", ] [[package]] -name = "http" -version = "1.3.1" +name = "html5ever" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1701,14 +1756,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1725,9 +1779,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1749,9 +1803,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png", @@ -1805,9 +1859,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1819,9 +1873,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1838,6 +1892,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1878,12 +1938,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1899,15 +1959,15 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1934,9 +1994,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -1970,7 +2030,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -1979,16 +2039,40 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2021,7 +2105,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "serde", "unicode-segmentation", ] @@ -2032,17 +2116,17 @@ version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.12.0", - "selectors", + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libappindicator" @@ -2064,15 +2148,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] [[package]] name = "libc" -version = "0.2.177" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -2084,32 +2168,23 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.7.3", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -2128,9 +2203,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] @@ -2149,13 +2224,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "time", ] @@ -2168,9 +2243,20 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", ] [[package]] @@ -2181,7 +2267,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2192,9 +2278,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -2213,9 +2299,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minisign-verify" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" [[package]] name = "miniz_oxide" @@ -2229,9 +2315,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2248,14 +2334,14 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -2265,8 +2351,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", - "jni-sys", + "bitflags 2.11.0", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -2286,7 +2372,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2295,19 +2381,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nodrop" version = "0.1.14" @@ -2316,9 +2389,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -2330,9 +2403,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -2345,9 +2418,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2355,14 +2428,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2374,27 +2447,11 @@ dependencies = [ "libc", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - [[package]] name = "objc2" -version = "0.5.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -2406,41 +2463,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", - "libc", - "objc2 0.6.3", - "objc2-cloud-kit", - "objc2-core-data", + "bitflags 2.11.0", + "block2", + "objc2", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -2449,9 +2476,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -2460,48 +2487,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-io-surface", ] -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2517,28 +2509,16 @@ dependencies = [ "cc", ] -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] - [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -2548,56 +2528,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2 0.6.3", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-foundation", ] [[package]] @@ -2606,20 +2551,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -2628,10 +2563,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -2640,21 +2575,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", - "objc2 0.6.3", + "bitflags 2.11.0", + "block2", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-javascript-core", - "objc2-security", + "objc2-foundation", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" @@ -2675,7 +2608,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "log", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -2690,6 +2623,12 @@ dependencies = [ "window-vibrancy 0.7.1", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2722,12 +2661,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2779,7 +2718,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2826,6 +2765,17 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -2846,6 +2796,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.8.0" @@ -2876,6 +2836,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -2900,7 +2870,20 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2927,14 +2910,23 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.1", + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2944,9 +2936,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2959,6 +2951,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2966,7 +2964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.0", + "indexmap 2.13.0", "quick-xml 0.38.4", "serde", "time", @@ -3029,6 +3027,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3051,11 +3059,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -3090,9 +3098,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3149,7 +3157,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3157,9 +3165,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -3170,7 +3178,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3192,9 +3200,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3205,6 +3213,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -3243,7 +3257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3273,7 +3287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3291,14 +3305,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -3333,7 +3347,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -3342,9 +3365,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3364,14 +3387,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3381,9 +3404,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3392,9 +3415,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rend" @@ -3407,9 +3430,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -3435,6 +3458,44 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3444,32 +3505,30 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] name = "rfd" -version = "0.15.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd", - "block2 0.6.2", + "block2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3480,7 +3539,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3488,9 +3547,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", "bytecheck", @@ -3506,9 +3565,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" dependencies = [ "proc-macro2", "quote", @@ -3517,9 +3576,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.39.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ "arrayvec", "borsh", @@ -3529,13 +3588,14 @@ dependencies = [ "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3548,11 +3608,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3561,9 +3621,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", "ring", @@ -3574,20 +3634,59 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.13.0" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.103.8" +name = "rustls-platform-verifier" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -3602,9 +3701,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3615,6 +3714,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3644,9 +3752,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -3663,15 +3771,9 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.110", + "syn 2.0.117", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -3684,6 +3786,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3691,14 +3816,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser", - "derive_more", + "cssparser 0.29.6", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", - "servo_arc", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", "smallvec", ] @@ -3751,7 +3895,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3762,20 +3906,20 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3786,7 +3930,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3800,9 +3944,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -3821,17 +3965,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10574371d41b0d9b2cff89418eda27da52bcaff2cc8741db26382a77c29131f1" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -3840,14 +3984,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3869,7 +4013,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3882,6 +4026,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3933,18 +4086,19 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -3960,15 +4114,15 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3978,34 +4132,34 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "softbuffer" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", - "cfg_aliases", - "core-graphics", - "foreign-types", "js-sys", - "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", + "tracing", "wasm-bindgen", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4040,12 +4194,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" @@ -4059,6 +4207,18 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" @@ -4071,6 +4231,18 @@ dependencies = [ "quote", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4107,9 +4279,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4133,7 +4305,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4151,35 +4323,33 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "core-foundation", "core-graphics", "crossbeam-channel", - "dispatch", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk", "ndk-context", "ndk-sys", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "parking_lot", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -4197,7 +4367,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4208,9 +4378,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -4225,9 +4395,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.4" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15524fc7959bfcaa051ba6d0b3fb1ef18e978de2176c7c6acb977f7fd14d35c7" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", @@ -4245,15 +4415,15 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4264,7 +4434,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tray-icon", "url", @@ -4276,9 +4446,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.3" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", @@ -4292,15 +4462,15 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -4314,9 +4484,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.110", + "syn 2.0.117", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", "uuid", @@ -4325,23 +4495,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.1" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" dependencies = [ "anyhow", "glob", @@ -4350,15 +4520,15 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-plugin-dialog" -version = "2.4.2" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ "log", "raw-window-handle", @@ -4368,15 +4538,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.4.4" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" dependencies = [ "anyhow", "dunce", @@ -4389,30 +4559,30 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.7.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5709c792b8630290b5d9811a1f8fe983dd925fc87c7fc7f4923616458cd00b6" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" dependencies = [ "android_logger", "byte-unit", "fern", "log", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "serde", "serde_json", "serde_repr", "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4430,16 +4600,16 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", ] [[package]] name = "tauri-plugin-shell" -version = "2.3.3" +version = "2.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" dependencies = [ "encoding_rs", "log", @@ -4452,15 +4622,15 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", ] [[package]] name = "tauri-plugin-updater" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", "dirs", @@ -4472,7 +4642,8 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", + "rustls", "semver", "serde", "serde_json", @@ -4480,7 +4651,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "url", @@ -4490,23 +4661,23 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", "gtk", "http", "jni", - "objc2 0.6.3", + "objc2", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webview2-com", @@ -4515,17 +4686,16 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7950f3bde6bcca6655bc5e76d3d6ec587ceb81032851ab4ddbe1f508bdea2729" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", "jni", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -4542,9 +4712,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -4552,7 +4722,7 @@ dependencies = [ "ctor", "dunce", "glob", - "html5ever", + "html5ever 0.29.1", "http", "infer", "json-patch", @@ -4570,8 +4740,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4586,7 +4756,7 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", "embed-resource", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -4596,19 +4766,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4625,6 +4795,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4636,11 +4816,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4651,25 +4831,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -4677,22 +4857,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4710,9 +4890,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4725,17 +4905,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", - "tracing", "windows-sys 0.61.2", ] @@ -4751,9 +4929,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -4776,17 +4954,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow 0.7.15", ] [[package]] @@ -4800,9 +4978,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -4813,7 +5000,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4824,7 +5011,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4833,36 +5020,36 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ - "indexmap 2.12.0", - "toml_datetime 0.7.3", + "indexmap 2.13.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow 0.7.13", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 0.7.13", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4875,11 +5062,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4905,9 +5092,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -4916,43 +5103,43 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "tray-icon" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", "dirs", "libappindicator", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -4976,13 +5163,13 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -5028,15 +5215,21 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -5046,14 +5239,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -5076,9 +5270,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8-width" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" [[package]] name = "utf8_iter" @@ -5088,21 +5282,21 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] [[package]] name = "value-bag" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "version-compare" @@ -5169,18 +5363,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -5191,22 +5394,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5214,31 +5414,53 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5248,70 +5470,22 @@ dependencies = [ ] [[package]] -name = "wayland-backend" -version = "0.3.11" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" -dependencies = [ - "bitflags 2.10.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" -dependencies = [ - "proc-macro2", - "quick-xml 0.37.5", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" -dependencies = [ - "dlib", - "log", - "pkg-config", + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", @@ -5328,10 +5502,22 @@ dependencies = [ ] [[package]] -name = "webkit2gtk" -version = "2.0.1" +name = "web_atoms" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -5353,9 +5539,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -5372,19 +5558,28 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.4" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] [[package]] name = "webview2-com" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", @@ -5396,22 +5591,22 @@ dependencies = [ [[package]] name = "webview2-com-macros" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "webview2-com-sys" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-core 0.61.2", ] @@ -5453,10 +5648,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -5468,10 +5663,10 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "windows-sys 0.60.2", "windows-version", @@ -5544,7 +5739,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5555,7 +5750,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5876,9 +6071,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -5895,9 +6099,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -5907,30 +6193,29 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" dependencies = [ "base64 0.22.1", - "block2 0.6.2", + "block2", "cookie", "crossbeam-channel", "dirs", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http", "javascriptcore-rs", "jni", - "kuchikiki", "libc", "ndk", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -5939,7 +6224,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6009,15 +6294,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -6033,16 +6318,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.13", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -6050,14 +6335,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -6065,34 +6350,33 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", - "winnow 0.7.13", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6112,7 +6396,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -6152,7 +6436,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6163,47 +6447,52 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.12.0", + "indexmap 2.13.0", "memchr", ] [[package]] -name = "zvariant" -version = "5.8.0" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", "serde", - "url", - "winnow 0.7.13", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.110", - "winnow 0.7.13", + "syn 2.0.117", + "winnow 0.7.15", ] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 3afbd71b..502f5d3b 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -19,17 +19,17 @@ log = "0.4.28" reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" -tauri = { version = "2.9.4", features = ["macos-private-api"] } -tauri-plugin-dialog = "2.4.2" -tauri-plugin-log = "2.7.1" -tauri-plugin-shell = "2.3.3" +tauri = { version = "2.10.3", features = ["macos-private-api"] } +tauri-plugin-dialog = "2.6.0" +tauri-plugin-log = "2.8.0" +tauri-plugin-shell = "2.3.5" tauri-plugin-notification = "2.3.3" -tauri-plugin-updater = "2" +tauri-plugin-updater = "2.10.0" tokio = { version = "1.38", features = ["rt-multi-thread", "time"] } url = "2.5" [build-dependencies] -tauri-build = { version = "2.5.3", features = [] } +tauri-build = { version = "2.5.6", features = [] } [target.'cfg(target_os = "macos")'.dependencies] window-vibrancy = "0.7.1" diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index a5335deb..c7964ad3 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -22,7 +22,6 @@ use std::{ }, time::Duration, }; -use tauri::utils::config::BackgroundThrottlingPolicy; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg(target_os = "macos")] use window_vibrancy::{ @@ -277,7 +276,7 @@ fn build_macos_menu( MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID, "Toggle Memory Debug", true, - Some("Cmd+Shift+D"), + Some("CmdOrCtrl+Shift+D"), )?; let help_dialog = MenuItem::with_id( @@ -2630,8 +2629,7 @@ fn create_window( .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) .decorations(true) .visible(false) - .initialization_script(&init_script) - .background_throttling(BackgroundThrottlingPolicy::Disabled); + .initialization_script(&init_script); let apply_restored_state = restored_state .as_ref() @@ -2693,8 +2691,7 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) .decorations(true) .visible(true) - .initialization_script(&splash_script) - .background_throttling(BackgroundThrottlingPolicy::Disabled); + .initialization_script(&splash_script); let apply_restored_state = restored_state .as_ref() diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 853ba53b..0979b05e 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -28,8 +28,7 @@ "y": 26 }, "dragDropEnabled": false, - "visible": false, - "backgroundThrottling": "disabled" + "visible": false } ], "security": { diff --git a/packages/ui/package.json b/packages/ui/package.json index c55e71c7..bcc7e67e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -85,7 +85,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.9.0", + "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 13875df0..b5de0414 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,12 +6,12 @@ import { ChatView } from '@/components/views'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; +import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; -import { useEventStream } from '@/hooks/useEventStream'; +// useEventStream removed — replaced by SyncProvider + SyncBridge import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; -import { useServerSessionStatus } from '@/hooks/useServerSessionStatus'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useRouter } from '@/hooks/useRouter'; @@ -25,9 +25,12 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { opencodeClient } from '@/lib/opencode/client'; +import { SyncProvider, useSessions } from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; +import { setOptimisticRefs } from '@/sync/session-actions'; import { useFontPreferences } from '@/hooks/useFontPreferences'; import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; @@ -45,7 +48,8 @@ const CLI_MISSING_ERROR_REGEX = const CLI_ONBOARDING_HEALTH_POLL_MS = 1500; const AboutDialogWrapper: React.FC = () => { - const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore(); + const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); + const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); return ( { }; }; +const EmbeddedSessionSelectionGate: React.FC<{ + embeddedSessionChat: EmbeddedSessionChatConfig | null; + isVSCodeRuntime: boolean; +}> = ({ embeddedSessionChat, isVSCodeRuntime }) => { + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + + React.useEffect(() => { + if (!embeddedSessionChat || isVSCodeRuntime) { + return; + } + + if (currentSessionId === embeddedSessionChat.sessionId) { + return; + } + + if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { + return; + } + + void setCurrentSession(embeddedSessionChat.sessionId); + }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); + + return null; +}; + +const SyncOptimisticBridge: React.FC = () => { + const sync = useSync(); + const addRef = React.useRef(sync.optimistic.add); + const removeRef = React.useRef(sync.optimistic.remove); + addRef.current = sync.optimistic.add; + removeRef.current = sync.optimistic.remove; + + React.useEffect(() => { + setOptimisticRefs( + (input) => addRef.current(input), + (input) => removeRef.current(input), + ); + }, []); + + return null; +}; + +function SyncAppEffects({ apis, embeddedBackgroundWorkEnabled }: { + apis: RuntimeAPIs; + embeddedBackgroundWorkEnabled: boolean; +}) { + const githubApi = embeddedBackgroundWorkEnabled ? apis.github : undefined; + useGitHubPrBackgroundTracking(githubApi, apis.git); + usePwaManifestSync(); + useSessionAutoCleanup(embeddedBackgroundWorkEnabled); + useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); + useKeyboardShortcuts(); + + return ; +} + function App({ apis }: AppProps) { - const { initializeApp, isInitialized, isConnected } = useConfigStore(); + const initializeApp = useConfigStore((s) => s.initializeApp); + const isInitialized = useConfigStore((s) => s.isInitialized); + const isConnected = useConfigStore((s) => s.isConnected); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); const loadAgents = useConfigStore((state) => state.loadAgents); - const { error, clearError, loadSessions } = useSessionStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const sessions = useSessionStore((state) => state.sessions); + const error = useSessionUIStore((s) => s.error); + const clearError = useSessionUIStore((s) => s.clearError); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setDirectory = useDirectoryStore((state) => state.setDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); @@ -118,6 +180,13 @@ function App({ apis }: AppProps) { const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; + React.useEffect(() => { + setStreamPerfEnabled(showMemoryDebug); + return () => { + setStreamPerfEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { setIsVSCodeRuntime(apis.runtime.isVSCode); }, [apis.runtime.isVSCode]); @@ -135,8 +204,6 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); - useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git); - React.useEffect(() => { if (typeof document === 'undefined') { return; @@ -202,70 +269,45 @@ function App({ apis }: AppProps) { init(); }, [initializeApp, isVSCodeRuntime]); - const startupRecoveryInProgressRef = React.useRef(false); - const startupRecoveryLastAttemptRef = React.useRef(0); - + // Startup recovery: poll until providers AND agents are loaded. + // loadProviders/loadAgents resolve normally even on failure (errors swallowed), + // so a reactive effect can't detect failure — we need an interval. React.useEffect(() => { - if (isVSCodeRuntime) { - return; - } - if (!isConnected) { - return; - } - if (providersCount > 0 && agentsCount > 0) { - return; - } - if (startupRecoveryInProgressRef.current) { - return; - } + if (isVSCodeRuntime || !isConnected) return; + if (providersCount > 0 && agentsCount > 0) return; - const now = Date.now(); - if (now - startupRecoveryLastAttemptRef.current < 750) { - return; - } - - startupRecoveryLastAttemptRef.current = now; - startupRecoveryInProgressRef.current = true; - - const repair = async () => { + let active = true; + const attempt = async () => { + const state = useConfigStore.getState(); + if (state.providers.length > 0 && state.agents.length > 0) return; try { - if (providersCount === 0) { - await loadProviders(); - } - if (agentsCount === 0) { - await loadAgents(); - } - } catch { - // Keep UI responsive; we'll retry on next cycle. - } finally { - startupRecoveryInProgressRef.current = false; - } + if (state.providers.length === 0) await loadProviders(); + if (useConfigStore.getState().agents.length === 0) await loadAgents(); + } catch { /* retry next interval */ } }; - void repair(); - }, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]); + void attempt(); + const id = setInterval(() => { if (active) void attempt(); }, 2000); + return () => { active = false; clearInterval(id); }; + }, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]); React.useEffect(() => { if (isSwitchingDirectory) { return; } - const syncDirectoryAndSessions = async () => { - // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races. - if (isVSCodeRuntime) { - return; - } + // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races. + if (isVSCodeRuntime) { + return; + } - if (!isConnected) { - return; - } - opencodeClient.setDirectory(currentDirectory); + if (!isConnected) { + return; + } + opencodeClient.setDirectory(currentDirectory); - await loadSessions(); - }; - - syncDirectoryAndSessions(); - }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]); + // Session loading is handled by the sync system's bootstrap — no manual loadSessions needed. + }, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]); React.useEffect(() => { if (!embeddedSessionChat || typeof window === 'undefined') { @@ -317,22 +359,6 @@ function App({ apis }: AppProps) { setDirectory(embeddedSessionChat.directory, { showOverlay: false }); }, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]); - React.useEffect(() => { - if (!embeddedSessionChat || isVSCodeRuntime) { - return; - } - - if (currentSessionId === embeddedSessionChat.sessionId) { - return; - } - - if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { - return; - } - - void setCurrentSession(embeddedSessionChat.sessionId); - }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); - React.useEffect(() => { if (!embeddedSessionChat || typeof window === 'undefined') { return; @@ -365,22 +391,17 @@ function App({ apis }: AppProps) { window.dispatchEvent(new Event('openchamber:app-ready')); }, [isInitialized, isSwitchingDirectory]); - useEventStream({ enabled: embeddedBackgroundWorkEnabled }); + // useEventStream replaced by SyncProvider + SyncBridge - // Server-authoritative session status polling - // Replaces SSE-dependent status updates with reliable HTTP polling - useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled }); + // Session attention now handled by notification-store via SSE events (session.idle/session.error) usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); - usePwaManifestSync(); usePwaInstallPrompt(); useWindowTitle(); useRouter(); - useKeyboardShortcuts(); - const handleToggleMemoryDebug = React.useCallback(() => { setShowMemoryDebug(prev => !prev); }, []); @@ -388,8 +409,6 @@ function App({ apis }: AppProps) { useMenuActions(handleToggleMemoryDebug); useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); - useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled }); - useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled }); React.useEffect(() => { if (embeddedSessionChat) { @@ -397,14 +416,19 @@ function App({ apis }: AppProps) { } const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && e.shiftKey && e.key === 'D') { + const isDebugShortcut = hasModifier(e) + && e.shiftKey + && !e.altKey + && (e.code === 'KeyD' || e.key.toLowerCase() === 'd'); + + if (isDebugShortcut) { e.preventDefault(); setShowMemoryDebug(prev => !prev); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); }, [embeddedSessionChat]); React.useEffect(() => { @@ -478,14 +502,18 @@ function App({ apis }: AppProps) { if (embeddedSessionChat) { return ( - - -
- - -
-
-
+ + + +
+ + + + +
+
+
+
); } @@ -493,62 +521,71 @@ function App({ apis }: AppProps) { // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { // Check if this is the Agent Manager panel - const panelType = typeof window !== 'undefined' - ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ + const panelType = typeof window !== 'undefined' + ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ : 'chat'; - + if (panelType === 'agentManager') { return ( - - -
- - -
-
-
-
- ); - } - - return ( - - - + +
- + +
-
-
+ + +
+ ); + } + + return ( + + + + + +
+ + + +
+
+
+
+
); } return ( - - - - - -
- - - - - {showMemoryDebug && ( - setShowMemoryDebug(false)} /> - )} -
-
-
-
-
-
+ + + + + + +
+ + + + + + {showMemoryDebug && ( + setShowMemoryDebug(false)} /> + )} +
+
+
+
+
+
+
); } diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d0724458..b36c5168 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { RiArrowLeftLine } from '@remixicon/react'; -import { useShallow } from 'zustand/react/shallow'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; @@ -14,10 +12,10 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; +import { useTimelineStaging } from '@/hooks/useTimelineStaging'; import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; -import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { cn } from '@/lib/utils'; @@ -26,6 +24,19 @@ import { flattenBlockingRequests, } from './lib/blockingRequests'; +// New sync system imports +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useStreamingStore } from '@/sync/streaming'; +import { + useSessionMessageRecords, + useSessions, + useDirectorySync, + useSessionStatus, +} from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; +import { getAllSyncSessions } from '@/sync/sync-refs'; + const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; @@ -71,101 +82,97 @@ const HYDRATING_SKELETON_ITEMS: Array<{ ]; export const ChatContainer: React.FC = () => { - const { - currentSessionId, - loadMessages, - loadMoreMessages, - updateViewportAnchor, - openNewSessionDraft, - setCurrentSession, - newSessionDraft, - } = useSessionStore( - useShallow((state) => ({ - currentSessionId: state.currentSessionId, - loadMessages: state.loadMessages, - loadMoreMessages: state.loadMoreMessages, - updateViewportAnchor: state.updateViewportAnchor, - openNewSessionDraft: state.openNewSessionDraft, - setCurrentSession: state.setCurrentSession, - newSessionDraft: state.newSessionDraft, - })) + // Session UI state + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor); + const isSyncing = useViewportStore((s) => s.isSyncing); + const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState); + + // Sync actions + const sync = useSync(); + const loadMessages = React.useCallback( + (sessionId: string) => sync.syncSession(sessionId), + [sync], + ); + const loadMoreMessages = React.useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId), + [sync], ); - const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore( - useShallow((state) => ({ - isSyncing: state.isSyncing, - messageStreamStates: state.messageStreamStates, - sessionMemoryStateMap: state.sessionMemoryState, - })) - ); + // UI store + const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore(); - const { - isTimelineDialogOpen, - setTimelineDialogOpen, - isExpandedInput, - stickyUserHeader, - chatRenderMode, - } = useUIStore(); - - const sessionMessages = useSessionStore( + // Streaming state + const streamingMessageId = useStreamingStore( React.useCallback( - (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES), - [currentSessionId] - ) + (s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null), + [currentSessionId], + ), + ); + // Messages from sync system + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + + // Sessions from sync system + const sessions = useSessions(); + + // Session status from sync system + const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; + + // Permissions & questions from sync system + const allPermissions = useDirectorySync( + React.useCallback((s) => s.permission ?? {}, []), + ); + const allQuestions = useDirectorySync( + React.useCallback((s) => s.question ?? {}, []), ); - const sessions = useSessionStore((state) => state.sessions); + // Convert Record → Map for blockingRequests helpers + const permissionsMap = React.useMemo(() => { + const m = new Map(); + for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]); + return m; + }, [allPermissions]); - const blockingRequestState = useSessionStore( - useShallow((state) => ({ - sessions: state.sessions, - permissions: state.permissions, - questions: state.questions, - })) - ); + const questionsMap = React.useMemo(() => { + const m = new Map(); + for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]); + return m; + }, [allQuestions]); const scopedSessionIds = React.useMemo( () => collectVisibleSessionIdsForBlockingRequests( - blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })), + sessions.map((session) => ({ id: session.id, parentID: session.parentID })), currentSessionId, ), - [blockingRequestState.sessions, currentSessionId] + [sessions, currentSessionId], ); const sessionPermissions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; - return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds); - }, [blockingRequestState.permissions, scopedSessionIds]); + return flattenBlockingRequests(permissionsMap, scopedSessionIds); + }, [permissionsMap, scopedSessionIds]); const sessionQuestions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; - return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds); - }, [blockingRequestState.questions, scopedSessionIds]); + return flattenBlockingRequests(questionsMap, scopedSessionIds); + }, [questionsMap, scopedSessionIds]); - const historyMeta = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null), - [currentSessionId] - ) - ); + // History metadata — use sync's hasMore/isLoading + const historyMeta = React.useMemo(() => { + if (!currentSessionId) return null; + return { + limit: sessionMessages.length, + complete: !sync.hasMore(currentSessionId), + loading: sync.isLoading(currentSessionId), + }; + }, [currentSessionId, sessionMessages.length, sync]); - const streamingMessageId = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null), - [currentSessionId] - ) - ); - - const sessionStatusForCurrent = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS), - [currentSessionId] - ) - ); - - const hasSessionMessagesEntry = useSessionStore( - React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId]) - ); + const hasSessionMessagesEntry = sessionMessages.length > 0 || (currentSessionId ? sync.hasMore(currentSessionId) : false); const { isMobile } = useDeviceInfo(); const draftOpen = Boolean(newSessionDraft?.open); @@ -173,24 +180,18 @@ export const ChatContainer: React.FC = () => { const messageListRef = React.useRef(null); const parentSession = React.useMemo(() => { - if (!currentSessionId) { - return null; - } - + if (!currentSessionId) return null; const current = sessions.find((session) => session.id === currentSessionId); const parentID = current?.parentID; - if (!parentID) { - return null; - } - - return sessions.find((session) => session.id === parentID) ?? null; + if (!parentID) return null; + return sessions.find((session) => session.id === parentID) + ?? getAllSyncSessions().find((session) => session.id === parentID) + ?? null; }, [currentSessionId, sessions]); const handleReturnToParentSession = React.useCallback(() => { - if (!parentSession) { - return; - } - void setCurrentSession(parentSession.id); + if (!parentSession) return; + setCurrentSession(parentSession.id); }, [parentSession, setCurrentSession]); const returnToParentButton = parentSession ? ( @@ -219,13 +220,15 @@ export const ChatContainer: React.FC = () => { }, [sessionPermissions, sessionQuestions]); const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); + const handleActiveTurnChange = React.useCallback((turnId: string | null) => { + activeTurnChangeRef.current(turnId); + }, []); const { scrollRef, handleMessageContentChange, getAnimationHandlers, scrollToBottom, - releasePinnedScroll, isPinned, isOverflowing, isProgrammaticFollowActive, @@ -238,16 +241,20 @@ export const ChatContainer: React.FC = () => { isSyncing, isMobile, chatRenderMode, - messageStreamStates, sessionPermissions: sessionBlockingCards, - onActiveTurnChange: (turnId) => { - activeTurnChangeRef.current(turnId); - }, + onActiveTurnChange: handleActiveTurnChange, + }); + + // Deferred timeline staging — renders 1 message on first paint, + // adds 3 per rAF frame to avoid blocking. + const { stagedMessages } = useTimelineStaging({ + sessionKey: currentSessionId ?? '', + messages: sessionMessages, }); const timelineController = useChatTimelineController({ sessionId: currentSessionId, - messages: sessionMessages, + messages: stagedMessages, historyMeta, scrollRef, messageListRef, @@ -272,16 +279,11 @@ export const ChatContainer: React.FC = () => { }); React.useEffect(() => { - if (typeof window === 'undefined' || !currentSessionId) { - return; - } + if (typeof window === 'undefined' || !currentSessionId) return; const handleSessionReselected = (event: Event) => { const customEvent = event as CustomEvent; - if (customEvent.detail !== currentSessionId) { - return; - } - + if (customEvent.detail !== currentSessionId) return; resumeToBottomInstant(); }; @@ -293,9 +295,7 @@ export const ChatContainer: React.FC = () => { React.useLayoutEffect(() => { const container = scrollRef.current; - if (!container) { - return; - } + if (!container) return; const updateChatScrollHeight = () => { container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`); @@ -329,23 +329,15 @@ export const ChatContainer: React.FC = () => { }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); - const hasHistoryMetadata = React.useMemo(() => { - return Boolean(historyMeta); - }, [historyMeta]); + const hasHistoryMetadata = Boolean(historyMeta); const isSessionHydrating = Boolean(currentSessionId) && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); React.useEffect(() => { - if (!currentSessionId) { - return; - } - - const hasSessionMessages = hasSessionMessagesEntry; - if (hasSessionMessages && hasHistoryMetadata) { - return; - } + if (!currentSessionId) return; + if (hasSessionMessagesEntry && hasHistoryMetadata) return; const load = async () => { await loadMessages(currentSessionId).finally(() => { @@ -523,6 +515,9 @@ export const ChatContainer: React.FC = () => { { )} - - { - releasePinnedScroll(); - return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false }); - }} - onScrollByTurnOffset={(offset) => { - releasePinnedScroll(); - void navigation.scrollByTurnOffset(offset); - }} - onResumeToLatest={navigation.resumeToLatest} - /> ); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index dd1526d7..b61102e1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -16,12 +16,16 @@ import { RiSendPlane2Line, } from '@remixicon/react'; import { BrowserVoiceButton } from '@/components/voice'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore'; +// sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useInputStore } from '@/sync/input-store'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import * as sessionActions from '@/sync/session-actions'; +import { useSessionMessageRecords } from '@/sync/sync-context'; import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { AttachedFilesList } from './FileAttachment'; @@ -40,8 +44,7 @@ import { MobileSessionStatusBar } from './MobileSessionStatusBar'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; -import { useFileStore } from '@/stores/fileStore'; -import { useMessageStore } from '@/stores/messageStore'; +// useMessageStore removed — messages now come from sync system import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; @@ -81,6 +84,28 @@ const VS_CODE_DROP_DATA_TYPES = [ const FILE_URI_PREFIX = 'file://'; +const encodeFilePath = (filepath: string): string => { + let normalized = filepath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(normalized)) { + normalized = `/${normalized}`; + } + return normalized + .split('/') + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment; + return encodeURIComponent(segment); + }) + .join('/'); +}; + +const toServerFileUrl = (filepath: string): string => { + const normalized = filepath.replace(/\\/g, '/').trim(); + if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) { + return normalized; + } + return `file://${encodeFilePath(normalized)}`; +}; + const isLikelyAbsolutePath = (value: string): boolean => ( value.startsWith('/') || value.startsWith('\\\\') @@ -273,7 +298,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const initialSessionIdRef = React.useRef(null); const [message, setMessage] = React.useState(() => { // Read per-session draft at mount time using the current session from the store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; initialSessionIdRef.current = sessionId; const draft = getStoredDraft(sessionId); if (draft) { @@ -313,25 +338,32 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const lastPersistedDraftRef = React.useRef>(new Map()); const currentSessionIdForDraftRef = React.useRef(null); - const sendMessage = useSessionStore((state) => state.sendMessage); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraft = useSessionStore((state) => state.newSessionDraft); + // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sendMessage = React.useRef((...args: any[]) => + Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), + ).current; + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); - const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation); - const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); - const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); - const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); - const attachedFiles = useSessionStore((state) => state.attachedFiles); - const addAttachedFile = useSessionStore((state) => state.addAttachedFile); - const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles); - const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection); - const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); - const pendingInputText = useSessionStore((state) => state.pendingInputText); - const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts); - const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId); + const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); + const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject); + const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); + const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); + const attachedFiles = useInputStore((s) => s.attachedFiles); + const addAttachedFile = useInputStore((s) => s.addAttachedFile); + const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); + const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); + const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const pendingInputText = useInputStore((s) => s.pendingInputText); + const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); + const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); + const abortCurrentOperation = React.useCallback( + (sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''), + [currentSessionId], + ); + const currentManagementSessionId = currentSessionId; const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); @@ -339,7 +371,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); - const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); + const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); const { working } = useAssistantStatus(); const { git: runtimeGit } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); @@ -351,10 +383,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const isDesktopExpanded = isExpandedInput && !isMobile; const chatInputRadius = 'var(--radius-lg)'; - const sendableAttachedFiles = React.useMemo( - () => attachedFiles.filter((file) => file.source !== 'server'), - [attachedFiles], - ); + const sendableAttachedFiles = attachedFiles; const hasInlineMentionForHighlight = React.useMemo(() => { if (!message || !message.includes('@') || inputMode === 'shell') { @@ -426,8 +455,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const sanitizeAttachmentsForSend = React.useCallback( (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) - .filter((file) => file.source !== 'server') - .map((file) => ({ ...file })), + .map((file) => ({ + ...file, + dataUrl: file.source === 'server' && file.serverPath + ? toServerFileUrl(file.serverPath) + : file.dataUrl, + })), [], ); @@ -498,7 +531,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo filename, mimeType: 'text/plain', size: 0, - dataUrl: normalizedServerPath, + dataUrl: toServerFileUrl(normalizedServerPath), source: 'server', serverPath: normalizedServerPath, }); @@ -565,15 +598,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // User message history for up/down arrow navigation // Get raw messages from store (stable reference) - const sessionMessages = useMessageStore( - React.useCallback( - (state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined), - [currentSessionId] - ) - ); + const sessionMessages = useSessionMessageRecords(currentSessionId ?? ""); // Derive user message history with useMemo to avoid infinite re-renders const userMessageHistory = React.useMemo(() => { - if (!sessionMessages) return []; + if (!sessionMessages || !currentSessionId) return []; return sessionMessages .filter((m) => m.info.role === 'user') .map((m) => { @@ -585,7 +613,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }) .filter((text) => text.length > 0) .reverse(); // Most recent first - }, [sessionMessages]); + }, [sessionMessages, currentSessionId]); // Keep messageRef in sync with message state React.useEffect(() => { @@ -866,6 +894,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo addToQueue(currentSessionId, { content: messageToQueue, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, + sendConfig: currentProviderId && currentModelId ? { + providerID: currentProviderId, + modelID: currentModelId, + agent: currentAgentName ?? undefined, + variant: currentVariant ?? undefined, + } : undefined, }); // Clear input and attachments @@ -877,7 +911,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); + }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; @@ -1037,25 +1071,26 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo .split(/\s+/)[0] ?.toLowerCase(); - // NEW: /undo - revert to last message (populates input with reverted message text) if (commandName === 'undo' && currentSessionId) { - await useSessionStore.getState().handleSlashUndo(currentSessionId); - // Don't clear message - pendingInputText will populate it with reverted message + await useSessionUIStore.getState().handleSlashUndo(currentSessionId); scrollToBottom?.({ instant: true, force: true }); - return; // Don't send to assistant + return; } - // NEW: /redo - unrevert or partial redo (populates input with message text) else if (commandName === 'redo' && currentSessionId) { - await useSessionStore.getState().handleSlashRedo(currentSessionId); - // Don't clear message - pendingInputText will populate it + await useSessionUIStore.getState().handleSlashRedo(currentSessionId); scrollToBottom?.({ instant: true, force: true }); - return; // Don't send to assistant + return; } - // NEW: /timeline - open timeline dialog - else if (commandName === 'timeline' && currentSessionId) { - setTimelineDialogOpen(true); - setMessage(''); - return; // Don't send to assistant + else if (commandName === 'compact' && currentSessionId) { + const { opencodeClient } = await import('@/lib/opencode/client'); + const sdk = opencodeClient.getSdkClient(); + const configState = useConfigStore.getState(); + await sdk.session.summarize({ + sessionID: currentSessionId, + modelID: configState.currentModelId || '', + providerID: configState.currentProviderId || '', + }); + return; } } @@ -1108,21 +1143,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { toast.error('Attachments are too large to send. Please try reducing the number or size of images.'); if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); } return; } if (isSoftNetworkError) { if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); toast.error('Failed to send attachments. Try fewer files or smaller images.'); } return; } if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); } toast.error(rawMessage || 'Message failed to send. Attachments restored.'); }); @@ -2560,7 +2595,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) { return; } - useSessionStore.getState().setDraftPreserveDirectoryOverride(false); + useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false); }, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]); const shouldShowDraftBranchSelector = React.useMemo(() => { @@ -2574,7 +2609,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]); const handleDraftProjectChange = React.useCallback((projectId: string) => { - const draft = useSessionStore.getState().newSessionDraft; + const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } @@ -2592,7 +2627,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]); const handleDraftDirectoryChange = React.useCallback((directory: string) => { - const draft = useSessionStore.getState().newSessionDraft; + const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index ca85ba6b..32d48551 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow'; import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useContextStore } from '@/stores/contextStore'; +import { useStreamingStore } from '@/sync/streaming'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useDeviceInfo } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -26,6 +29,8 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; +import { streamPerfCount } from '@/stores/utils/streamDebug'; +import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); @@ -123,6 +128,8 @@ interface ChatMessageProps { animationHandlers?: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; turnGroupingContext?: TurnGroupingContext; + assistantHeaderMessageId?: string; + isInActiveTurn?: boolean; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; } @@ -134,6 +141,8 @@ const ChatMessage: React.FC = ({ onContentChange, animationHandlers, turnGroupingContext, + assistantHeaderMessageId, + isInActiveTurn = false, animateUserOnMount = false, onUserAnimationConsumed, }) => { @@ -141,36 +150,31 @@ const ChatMessage: React.FC = ({ const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); - const sessionState = useSessionStore( - useShallow((state) => ({ - lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null, - isStreamingMessage: (() => { - const sessionId = - (message.info as { sessionID?: string }).sessionID ?? - state.currentSessionId ?? - null; - if (!sessionId) return false; - return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id; - })(), - currentSessionId: state.currentSessionId, - getAgentModelForSession: state.getAgentModelForSession, - getSessionModelSelection: state.getSessionModelSelection, - revertToMessage: state.revertToMessage, - forkFromMessage: state.forkFromMessage, - })) - ); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const streamState = useStreamingStore((s) => s.messageStreamStates.get(message.info.id)); + const lifecyclePhase = isInActiveTurn ? (streamState?.phase ?? null) : null; - const { - lifecyclePhase, - isStreamingMessage, - currentSessionId, - getAgentModelForSession, - getSessionModelSelection, - revertToMessage, - forkFromMessage, - } = sessionState; + const msgSessionId = (message.info as { sessionID?: string }).sessionID ?? currentSessionId ?? null; + const streamingMsgForSession = useStreamingStore((s) => msgSessionId ? s.streamingMessageIds.get(msgSessionId) ?? null : null); + const isStreamingMessage = isInActiveTurn ? streamingMsgForSession === message.info.id : false; + const hasActiveStreamInSession = typeof streamingMsgForSession === 'string' && streamingMsgForSession.length > 0; - const providers = useConfigStore((state) => state.providers); + const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); + const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); + const revertToMessage = sessionActions.revertToMessage; + const forkFromMessage = sessionActions.forkFromMessage; + + streamPerfCount('ui.chat_message.render'); + if (isStreamingMessage) { + streamPerfCount('ui.chat_message.render.streaming'); + } else if (hasActiveStreamInSession) { + streamPerfCount('ui.chat_message.render.static_during_stream'); + if (!isInActiveTurn) { + streamPerfCount('ui.chat_message.render.static_outside_active_turn_during_stream'); + } + } + + const providers = useConfigStore.getState().providers; const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, @@ -211,11 +215,11 @@ const ChatMessage: React.FC = ({ const sessionId = message.info.sessionID; - // Subscribe to context changes so badges update immediately on mode switches. + // Keep non-active-turn rows detached from context-store churn. const { currentContextAgent, savedSessionAgentSelection } = useContextStore( useShallow((state) => ({ - currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined, - savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, + currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined, + savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, })) ); @@ -607,7 +611,7 @@ const ChatMessage: React.FC = ({ }, [message.info.id]); React.useEffect(() => { - const headerMessageId = turnGroupingContext?.headerMessageId; + const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (isUser || !headerMessageId || headerMessageId !== message.info.id) { return; } @@ -616,13 +620,13 @@ const ChatMessage: React.FC = ({ if (isCurrentlyStreaming) { setHasStartedStreamingHeader(true); } - }, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]); + }, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]); const shouldShowHeader = React.useMemo(() => { if (isUser) return true; // Use turn grouping context if available for more precise control - const headerMessageId = turnGroupingContext?.headerMessageId; + const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (headerMessageId) { // For turn grouping: only show header for the first assistant message in the turn const isFirstAssistantInTurn = message.info.id === headerMessageId; @@ -644,7 +648,7 @@ const ChatMessage: React.FC = ({ // Ungrouped fallback path: always show assistant header. return true; - }, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); + }, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); const handleCopyCode = React.useCallback((code: string) => { void copyTextToClipboard(code).then((result) => { @@ -1086,6 +1090,7 @@ const ChatMessage: React.FC = ({ )} = ({ ); }; -export default React.memo(ChatMessage); +export default React.memo(ChatMessage, (prev, next) => { + return areRenderRelevantMessageInfoEqual(prev.message.info, next.message.info) + && areRenderRelevantPartsEqual(prev.message.parts, next.message.parts) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.onContentChange === next.onContentChange + && prev.turnGroupingContext === next.turnGroupingContext + && prev.assistantHeaderMessageId === next.assistantHeaderMessageId + && prev.isInActiveTurn === next.isInActiveTurn + && prev.animateUserOnMount === next.animateUserOnMount + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed; +}); diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 97565ec3..35a290b5 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react'; +import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine } from '@remixicon/react'; import { cn, fuzzyMatch } from '@/lib/utils'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessages } from '@/sync/sync-context'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; -import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface CommandInfo { @@ -42,16 +42,9 @@ export const CommandAutocomplete = React.forwardRef { - const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore( - useShallow((state) => { - const sessionId = state.currentSessionId; - const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0; - return { - hasMessagesInCurrentSession: messageCount > 0, - currentSessionId: sessionId, - }; - }) - ); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionMessages = useSessionMessages(currentSessionId ?? ''); + const hasMessagesInCurrentSession = sessionMessages.length > 0; const hasSession = Boolean(currentSessionId); const [commands, setCommands] = React.useState([]); @@ -114,7 +107,6 @@ export const CommandAutocomplete = React.forwardRef; case 'redo': return ; - case 'timeline': - return ; case 'compact': return ; case 'test': diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 8930dab8..7a19c132 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -1,6 +1,7 @@ import React, { useRef, memo } from 'react'; import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react'; -import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore'; +import { useInputStore } from '@/sync/input-store'; +import type { AttachedFile } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; @@ -13,7 +14,7 @@ import type { ToolPopupContent } from './message/types'; export const FileAttachmentButton = memo(() => { const fileInputRef = useRef(null); - const { addAttachedFile } = useSessionStore(); + const { addAttachedFile } = useInputStore(); const { isMobile } = useUIStore(); const isVSCodeRuntime = useIsVSCodeRuntime(); const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; @@ -255,7 +256,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { FileChip.displayName = 'FileChip'; export const AttachedFilesList = memo(() => { - const { attachedFiles, removeAttachedFile } = useSessionStore(); + const { attachedFiles, removeAttachedFile } = useInputStore(); const localFiles = attachedFiles.filter((file) => file.source !== 'server'); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 5a9eaa0b..a79ab3fb 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1,11 +1,8 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; -import { flushSync } from 'react-dom'; -import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from '@tanstack/react-virtual'; -import { useShallow } from 'zustand/react/shallow'; -import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtual'; import ChatMessage from './ChatMessage'; +import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import TurnItem from './components/TurnItem'; @@ -17,23 +14,16 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; -import { useStageTurns } from './lib/turns/stageTurns'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useStreamingStore } from '@/sync/streaming'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionStatus } from '@/sync/sync-context'; import { useDeviceInfo } from '@/lib/device'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; -import { useAssistantStatus } from '@/hooks/useAssistantStatus'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { StatusRow } from './StatusRow'; - -const MESSAGE_VIRTUALIZE_THRESHOLD = 40; -const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2; -const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4; -const TURN_ESTIMATE_BASE_PX = 120; -const TURN_ESTIMATE_PER_ASSISTANT_PX = 120; -const TURN_ESTIMATE_MAX_PX = 1400; +import { StatusRowContainer } from './StatusRowContainer'; +import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; const useStableEvent = (handler: (...args: TArgs) => TResult) => { const handlerRef = React.useRef(handler); @@ -44,49 +34,6 @@ const useStableEvent = (handler: (...args: TAr return React.useCallback((...args: TArgs) => handlerRef.current(...args), []); }; -type MessageListVirtualizerOptions = Omit< - ReactVirtualizerOptions, - 'scrollToFn' | 'observeElementRect' | 'observeElementOffset' -> - -const useMessageListVirtualizer = ( - options: MessageListVirtualizerOptions, -): Virtualizer => { - const [, forceRender] = React.useReducer(() => ({}), {}); - const { useFlushSync = true, onChange, ...baseOptions } = options; - - const handleChange = React.useCallback((instance: Virtualizer, sync: boolean) => { - if (useFlushSync && sync) { - flushSync(forceRender); - } else { - forceRender(); - } - - onChange?.(instance, sync); - }, [onChange, useFlushSync]); - - const [virtualizer] = React.useState(() => new Virtualizer({ - ...baseOptions, - onChange: handleChange, - observeElementRect, - observeElementOffset, - scrollToFn: elementScroll, - })); - - virtualizer.setOptions({ - ...baseOptions, - onChange: handleChange, - observeElementRect, - observeElementOffset, - scrollToFn: elementScroll, - }); - - React.useLayoutEffect(() => virtualizer._didMount(), [virtualizer]); - React.useLayoutEffect(() => virtualizer._willUpdate(), [virtualizer]); - - return virtualizer; -}; - const USER_SHELL_MARKER = 'The following tool was executed by the user'; const resolveMessageRole = (message: ChatMessageEntry): string | null => { @@ -387,6 +334,8 @@ interface MessageRowProps { previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; turnGroupingContext?: TurnGroupingContext; + assistantHeaderMessageId?: string; + isInActiveTurn?: boolean; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; onContentChange: (reason?: ContentChangeReason) => void; @@ -394,11 +343,13 @@ interface MessageRowProps { scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } -const MessageRow = React.memo(({ +const MessageRow = React.memo(({ message, previousMessage, nextMessage, turnGroupingContext, + assistantHeaderMessageId, + isInActiveTurn, animateUserOnMount, onUserAnimationConsumed, onContentChange, @@ -416,8 +367,39 @@ const MessageRow = React.memo(({ animationHandlers={animationHandlers} scrollToBottom={scrollToBottom} turnGroupingContext={turnGroupingContext} + assistantHeaderMessageId={assistantHeaderMessageId} + isInActiveTurn={isInActiveTurn} /> ); +}, (prev, next) => { + const prevTurn = prev.turnGroupingContext; + const nextTurn = next.turnGroupingContext; + + return areRenderRelevantMessagesEqual(prev.message, next.message) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.animateUserOnMount === next.animateUserOnMount + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.onContentChange === next.onContentChange + && prev.scrollToBottom === next.scrollToBottom + && prevTurn?.turnId === nextTurn?.turnId + && prevTurn?.isFirstAssistantInTurn === nextTurn?.isFirstAssistantInTurn + && prevTurn?.isLastAssistantInTurn === nextTurn?.isLastAssistantInTurn + && prevTurn?.activityOwnerMessageId === nextTurn?.activityOwnerMessageId + && prevTurn?.isWorking === nextTurn?.isWorking + && prevTurn?.isGroupExpanded === nextTurn?.isGroupExpanded + && prevTurn?.toggleGroup === nextTurn?.toggleGroup + && prevTurn?.activityGroupSegments === nextTurn?.activityGroupSegments + && prevTurn?.activityParts === nextTurn?.activityParts + && prev.assistantHeaderMessageId === next.assistantHeaderMessageId + && prev.isInActiveTurn === next.isInActiveTurn + && prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk + && prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete + && prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate + && prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart + && prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled + && prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock + && prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange; }); MessageRow.displayName = 'MessageRow'; @@ -436,6 +418,7 @@ interface TurnBlockProps { stickyUserHeader?: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } const TurnBlock: React.FC = ({ @@ -452,8 +435,12 @@ const TurnBlock: React.FC = ({ stickyUserHeader = true, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded }; + const handleToggleTurnGroup = React.useCallback(() => { + onToggleTurnGroup(turn.turnId); + }, [onToggleTurnGroup, turn.turnId]); const messageOrder = React.useMemo(() => { const ordered = [turn.userMessage, ...turn.assistantMessages]; @@ -464,20 +451,45 @@ const TurnBlock: React.FC = ({ return { ordered, lookup }; }, [turn.assistantMessages, turn.userMessage]); + const streamingAssistantMessageId = React.useMemo(() => { + if (activeStreamingMessageId && turn.assistantMessages.some((assistant) => assistant.info.id === activeStreamingMessageId)) { + return activeStreamingMessageId; + } + + for (let index = turn.assistantMessages.length - 1; index >= 0; index -= 1) { + const assistant = turn.assistantMessages[index]; + if (!isAssistantMessageCompleted(assistant)) { + return assistant.info.id; + } + } + + return null; + }, [activeStreamingMessageId, turn.assistantMessages]); + const visibleAssistantMessages = React.useMemo(() => { if (chatRenderMode === 'live') { return turn.assistantMessages; } + const completed = turn.assistantMessages.filter(isAssistantMessageCompleted); if (completed.length === turn.assistantMessages.length) { return turn.assistantMessages; } + + if (streamingAssistantMessageId) { + const completedIds = new Set(completed.map((assistant) => assistant.info.id)); + return turn.assistantMessages.filter((assistant) => ( + completedIds.has(assistant.info.id) + || assistant.info.id === streamingAssistantMessageId + )); + } + if (completed.length > 0) { return completed; } const firstAssistant = turn.assistantMessages[0]; return firstAssistant ? [firstAssistant] : []; - }, [chatRenderMode, turn.assistantMessages]); + }, [chatRenderMode, streamingAssistantMessageId, turn.assistantMessages]); const completedAssistantMessages = React.useMemo(() => { if (chatRenderMode !== 'sorted') { @@ -498,30 +510,49 @@ const TurnBlock: React.FC = ({ return new Set(completedAssistantMessages.map((assistant) => assistant.info.id)); }, [completedAssistantMessages]); + const visibleActivityMessageIdSet = React.useMemo(() => { + const ids = new Set(completedAssistantIdSet); + if (streamingAssistantMessageId) { + ids.add(streamingAssistantMessageId); + } + return ids; + }, [completedAssistantIdSet, streamingAssistantMessageId]); + + const turnIsInActiveStream = React.useMemo(() => { + return turnContainsMessageId(turn, streamingAssistantMessageId); + }, [turn, streamingAssistantMessageId]); + + const activityOwnerMessageId = React.useMemo(() => { + if (turnIsInActiveStream && streamingAssistantMessageId) { + return streamingAssistantMessageId; + } + return visibleAssistantMessages[0]?.info.id; + }, [streamingAssistantMessageId, turnIsInActiveStream, visibleAssistantMessages]); + const visibleActivityParts = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activityParts; } - if (completedAssistantMessages.length === turn.assistantMessages.length) { + if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activityParts; } - return turn.activityParts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); - }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activityParts, turn.assistantMessages.length]); + return turn.activityParts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); + }, [chatRenderMode, visibleActivityMessageIdSet, turn.activityParts, turn.assistantMessages.length]); const visibleActivitySegments = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activitySegments; } - if (completedAssistantMessages.length === turn.assistantMessages.length) { + if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activitySegments; } return turn.activitySegments .map((segment) => { - const parts = segment.parts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); + const parts = segment.parts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); if (parts.length === 0) { return null; } - const anchorMessageId = completedAssistantIdSet.has(segment.anchorMessageId) + const anchorMessageId = visibleActivityMessageIdSet.has(segment.anchorMessageId) ? segment.anchorMessageId : parts[0]?.messageId; if (!anchorMessageId) { @@ -534,7 +565,7 @@ const TurnBlock: React.FC = ({ }; }) .filter((segment): segment is NonNullable => segment !== null); - }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activitySegments, turn.assistantMessages.length]); + }, [chatRenderMode, visibleActivityMessageIdSet, turn.activitySegments, turn.assistantMessages.length]); const turnGroupingContextBase = React.useMemo(() => { const userCreatedAt = (turn.userMessage.info.time as { created?: number } | undefined)?.created; @@ -558,24 +589,50 @@ const TurnBlock: React.FC = ({ const renderMessage = React.useCallback( (message: ChatMessageEntry) => { + const messageRole = resolveMessageRole(message); + const isUserMessage = messageRole === 'user'; const messageIndex = messageOrder.lookup.get(message.info.id); - const previousMessage = typeof messageIndex === 'number' && messageIndex > 0 - ? messageOrder.ordered[messageIndex - 1] - : undefined; - const nextMessage = typeof messageIndex === 'number' && messageIndex < messageOrder.ordered.length - 1 - ? messageOrder.ordered[messageIndex + 1] - : undefined; - const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1; + const isAssistantMessage = assistantIndex >= 0; + const isFirstAssistant = assistantIndex === 0; + const isLastAssistant = assistantIndex === visibleAssistantMessages.length - 1; + const isActivityOwner = Boolean(activityOwnerMessageId) && message.info.id === activityOwnerMessageId; + const shouldAttachFullTurnContext = chatRenderMode === 'sorted' + ? isAssistantMessage + : (isActivityOwner || isFirstAssistant || isLastAssistant); + const assistantHeaderMessageId = visibleAssistantMessages[0]?.info.id ?? turn.headerMessageId; - const turnGroupingContext = assistantIndex >= 0 + const previousMessage = isUserMessage + ? undefined + : (isAssistantMessage + ? (isFirstAssistant + ? turn.userMessage + : undefined) + : (typeof messageIndex === 'number' && messageIndex > 0 + ? messageOrder.ordered[messageIndex - 1] + : undefined)); + const nextMessage = undefined; + + const turnGroupingContext = isAssistantMessage ? { - ...turnGroupingContextBase, - isFirstAssistantInTurn: assistantIndex === 0, - isLastAssistantInTurn: assistantIndex === visibleAssistantMessages.length - 1, - isWorking: isLastTurn && sessionIsWorking, - isGroupExpanded: turnUiState.isExpanded, - toggleGroup: () => onToggleTurnGroup(turn.turnId), + turnId: turn.turnId, + activityOwnerMessageId, + isFirstAssistantInTurn: isFirstAssistant, + isLastAssistantInTurn: isLastAssistant, + isWorking: isLastTurn && sessionIsWorking && message.info.id === streamingAssistantMessageId, + hasTools: turn.hasTools, + hasReasoning: turn.hasReasoning, + ...(shouldAttachFullTurnContext ? { + summaryBody: turnGroupingContextBase.summaryBody, + activityParts: turnGroupingContextBase.activityParts, + activityGroupSegments: turnGroupingContextBase.activityGroupSegments, + headerMessageId: turnGroupingContextBase.headerMessageId, + diffStats: turnGroupingContextBase.diffStats, + userMessageCreatedAt: turnGroupingContextBase.userMessageCreatedAt, + userMessageVariant: turnGroupingContextBase.userMessageVariant, + isGroupExpanded: turnUiState.isExpanded, + toggleGroup: handleToggleTurnGroup, + } : {}), } satisfies TurnGroupingContext : undefined; @@ -586,6 +643,8 @@ const TurnBlock: React.FC = ({ previousMessage={previousMessage} nextMessage={nextMessage} turnGroupingContext={turnGroupingContext} + assistantHeaderMessageId={assistantHeaderMessageId} + isInActiveTurn={Boolean(streamingAssistantMessageId) && message.info.id === streamingAssistantMessageId} animateUserOnMount={shouldAnimateUserMessage(message)} onUserAnimationConsumed={onUserAnimationConsumed} onContentChange={onMessageContentChange} @@ -602,14 +661,21 @@ const TurnBlock: React.FC = ({ onMessageContentChange, scrollToBottom, sessionIsWorking, + chatRenderMode, + turn.headerMessageId, + turn.hasReasoning, + turn.hasTools, turn.turnId, + turn.userMessage, turnUiState.isExpanded, turnGroupingContextBase, + streamingAssistantMessageId, visibleAssistantMessages, visibleAssistantIds, + activityOwnerMessageId, shouldAnimateUserMessage, onUserAnimationConsumed, - onToggleTurnGroup, + handleToggleTurnGroup, ] ); @@ -639,6 +705,7 @@ interface UngroupedMessageRowProps { scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } const UngroupedMessageRow: React.FC = React.memo(({ @@ -650,6 +717,7 @@ const UngroupedMessageRow: React.FC = React.memo(({ scrollToBottom, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { return ( = React.memo(({ onContentChange={onMessageContentChange} animationHandlers={getAnimationHandlers(message.info.id)} scrollToBottom={scrollToBottom} + isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId} /> ); +}, (prev, next) => { + return areRenderRelevantMessagesEqual(prev.message, next.message) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.onMessageContentChange === next.onMessageContentChange + && prev.getAnimationHandlers === next.getAnimationHandlers + && prev.scrollToBottom === next.scrollToBottom + && prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.activeStreamingMessageId === next.activeStreamingMessageId; }); UngroupedMessageRow.displayName = 'UngroupedMessageRow'; @@ -680,8 +759,21 @@ interface MessageListEntryProps { chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } +const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => { + if (!messageId) { + return false; + } + + if (turn.userMessage.info.id === messageId) { + return true; + } + + return turn.assistantMessages.some((assistant) => assistant.info.id === messageId); +}; + const MessageListEntry: React.FC = React.memo(({ entry, onMessageContentChange, @@ -695,6 +787,7 @@ const MessageListEntry: React.FC = React.memo(({ chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { if (entry.kind === 'ungrouped') { return ( @@ -707,6 +800,7 @@ const MessageListEntry: React.FC = React.memo(({ scrollToBottom={scrollToBottom} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} /> ); } @@ -722,6 +816,7 @@ const MessageListEntry: React.FC = React.memo(({ chatRenderMode={chatRenderMode} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} onMessageContentChange={onMessageContentChange} getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} @@ -757,14 +852,31 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro return false; } + if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) { + const prevAffected = turnContainsMessageId(prevEntry.turn, prevProps.activeStreamingMessageId); + const nextAffected = turnContainsMessageId(nextEntry.turn, nextProps.activeStreamingMessageId); + if (prevAffected || nextAffected) { + return false; + } + } + return true; } if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') { + if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) { + const messageId = prevEntry.message.info.id; + const prevActive = prevProps.activeStreamingMessageId === messageId; + const nextActive = nextProps.activeStreamingMessageId === messageId; + if (prevActive !== nextActive) { + return false; + } + } + return ( - prevEntry.message === nextEntry.message - && prevEntry.previousMessage === nextEntry.previousMessage - && prevEntry.nextMessage === nextEntry.nextMessage + areRenderRelevantMessagesEqual(prevEntry.message, nextEntry.message) + && areOptionalRenderRelevantMessagesEqual(prevEntry.previousMessage, nextEntry.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prevEntry.nextMessage, nextEntry.nextMessage) ); } @@ -785,7 +897,8 @@ const MessageListContent: React.FC<{ chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; -}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => { + activeStreamingMessageId?: string | null; +}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => { const renderEntry = React.useCallback((entry: RenderEntry) => { return ( ); - }, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); + }, [activeStreamingMessageId, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); return ( ); }; +const StreamingTailContent: React.FC<{ + entry: RenderEntry; + onMessageContentChange: (reason?: ContentChangeReason) => void; + getAnimationHandlers: (messageId: string) => AnimationHandlers; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + stickyUserHeader: boolean; + sessionIsWorking: boolean; + defaultActivityExpanded: boolean; + turnUiStates: Map; + onToggleTurnGroup: (turnId: string) => void; + chatRenderMode: 'sorted' | 'live'; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; +}> = React.memo(({ + entry, + onMessageContentChange, + getAnimationHandlers, + scrollToBottom, + stickyUserHeader, + sessionIsWorking, + defaultActivityExpanded, + turnUiStates, + onToggleTurnGroup, + chatRenderMode, + shouldAnimateUserMessage, + onUserAnimationConsumed, + activeStreamingMessageId, +}) => { + return ( + + ); +}, (prev, next) => { + return prev.entry === next.entry + && prev.onMessageContentChange === next.onMessageContentChange + && prev.getAnimationHandlers === next.getAnimationHandlers + && prev.scrollToBottom === next.scrollToBottom + && prev.stickyUserHeader === next.stickyUserHeader + && prev.sessionIsWorking === next.sessionIsWorking + && prev.defaultActivityExpanded === next.defaultActivityExpanded + && prev.turnUiStates === next.turnUiStates + && prev.onToggleTurnGroup === next.onToggleTurnGroup + && prev.chatRenderMode === next.chatRenderMode + && prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.activeStreamingMessageId === next.activeStreamingMessageId; +}); + +StreamingTailContent.displayName = 'StreamingTailContent'; + const MessageList = React.forwardRef(({ sessionKey, turnStart, - disableStaging, + disableStaging: _disableStaging, messages, permissions, questions, @@ -826,10 +1004,11 @@ const MessageList = React.forwardRef(({ scrollToBottom, scrollRef, }, ref) => { + streamPerfCount('ui.message_list.render'); + void _disableStaging; const { isMobile } = useDeviceInfo(); const { isWorking: sessionIsWorking } = useCurrentSessionActivity(); - const { working } = useAssistantStatus(); - const currentAgentName = useConfigStore((state) => state.currentAgentName); + const activeStreamingMessageId = useStreamingStore((state) => state.streamingMessageIds.get(sessionKey) ?? null); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const activityRenderMode = useUIStore((state) => state.activityRenderMode); @@ -845,6 +1024,13 @@ const MessageList = React.forwardRef(({ output: ChatMessageEntry[]; outputIndexById: Map; } | null>(null); + const staticRenderEntriesCacheRef = React.useRef<{ + input: ChatMessageEntry[]; + output: RenderEntry[]; + staticTurns: TurnRecord[]; + lastTurnId: string | null; + ungroupedMessageIds: Set; + } | null>(null); const stableOnMessageContentChange = useStableEvent(onMessageContentChange); const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); @@ -874,7 +1060,7 @@ const MessageList = React.forwardRef(({ }, [defaultActivityExpanded]); - const baseDisplayMessages = React.useMemo(() => { + const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => { const cached = baseDisplayCacheRef.current; const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage)); @@ -972,22 +1158,21 @@ const MessageList = React.forwardRef(({ }; return output; - }, [messages]); + }), [messages]); - const activeRetryStatus = useSessionStore( - useShallow((state) => { - const sessionId = state.currentSessionId; - if (!sessionId) return null; - const status = state.sessionStatus?.get(sessionId); - if (!status || status.type !== 'retry') return null; - const rawMessage = typeof status.message === 'string' ? status.message.trim() : ''; - return { - sessionId, - message: rawMessage || 'Quota limit reached. Retrying automatically.', - confirmedAt: status.confirmedAt, - }; - }) - ); + const currentSessionIdForRetry = useSessionUIStore((s) => s.currentSessionId); + const retryStatusRaw = useSessionStatus(currentSessionIdForRetry ?? ''); + const activeRetryStatus = React.useMemo(() => { + if (!currentSessionIdForRetry) return null; + const status = retryStatusRaw; + if (!status || status.type !== 'retry') return null; + const rawMessage = typeof (status as { message?: string }).message === 'string' ? ((status as { message?: string }).message ?? '').trim() : ''; + return { + sessionId: currentSessionIdForRetry, + message: rawMessage || 'Quota limit reached. Retrying automatically.', + confirmedAt: (status as { confirmedAt?: number }).confirmedAt, + }; + }, [currentSessionIdForRetry, retryStatusRaw]); const activeRetrySessionId = activeRetryStatus?.sessionId ?? null; const activeRetryMessage = activeRetryStatus?.message @@ -1019,27 +1204,63 @@ const MessageList = React.forwardRef(({ } }, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]); - const displayMessages = React.useMemo(() => { + const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => { return applyRetryOverlay(baseDisplayMessages, { sessionId: activeRetrySessionId, message: activeRetryMessage, confirmedAt: activeRetryConfirmedAt, fallbackTimestamp: fallbackRetryTimestamp, }); - }, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]); + }), [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]); const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, { + sessionKey, showTextJustificationActivity: chatRenderMode === 'sorted', }); - const turns = React.useMemo(() => { - if (!streamingTurn) { - return staticTurns; - } - return [...staticTurns, streamingTurn]; - }, [staticTurns, streamingTurn]); + const staticRenderEntries = React.useMemo(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => { + const cached = staticRenderEntriesCacheRef.current; + const lastMessage = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1] : undefined; + const hasTrailingCandidate = Boolean(lastMessage) && ( + (streamingTurn + ? (streamingTurn.userMessage.info.id === lastMessage?.info.id + || streamingTurn.assistantMessages.some((assistant) => assistant.info.id === lastMessage?.info.id)) + : false) + || (lastMessage ? projection.ungroupedMessageIds.has(lastMessage.info.id) : false) + ); - const renderEntries = React.useMemo(() => { - const turnEntries = turns.map((turn) => ({ + if ( + cached + && hasTrailingCandidate + && cached.input.length === displayMessages.length + && cached.staticTurns === staticTurns + && cached.lastTurnId === projection.lastTurnId + && cached.ungroupedMessageIds === projection.ungroupedMessageIds + && displayMessages.length > 0 + ) { + let changedCount = 0; + let changedIndex = -1; + let idsStable = true; + + for (let index = 0; index < displayMessages.length; index += 1) { + if (displayMessages[index]?.info?.id !== cached.input[index]?.info?.id) { + idsStable = false; + break; + } + if (displayMessages[index] !== cached.input[index]) { + changedCount += 1; + changedIndex = index; + if (changedCount > 1) { + break; + } + } + } + + if (idsStable && changedCount === 1 && changedIndex === displayMessages.length - 1) { + return cached.output; + } + } + + const turnEntries = staticTurns.map((turn) => ({ kind: 'turn' as const, key: `turn:${turn.turnId}`, turn, @@ -1076,22 +1297,53 @@ const MessageList = React.forwardRef(({ }); }); + staticRenderEntriesCacheRef.current = { + input: displayMessages, + output: orderedEntries, + staticTurns, + lastTurnId: projection.lastTurnId, + ungroupedMessageIds: projection.ungroupedMessageIds, + }; + return orderedEntries; - }, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, turns]); + }), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns, streamingTurn]); - const staging = useStageTurns({ - sessionKey, - turnStart, - totalTurns: renderEntries.length, - disabled: disableStaging, - }); - - const stagedEntries = React.useMemo(() => { - if (staging.stageStartIndex <= 0) { - return renderEntries; + const trailingStreamingEntry = React.useMemo(() => { + if (streamingTurn) { + return { + kind: 'turn', + key: `turn:${streamingTurn.turnId}`, + turn: streamingTurn, + isLastTurn: streamingTurn.turnId === projection.lastTurnId, + } satisfies RenderEntry; } - return renderEntries.slice(staging.stageStartIndex); - }, [renderEntries, staging.stageStartIndex]); + + if (projection.ungroupedMessageIds.size === 0) { + return undefined; + } + + const lastMessage = displayMessages[displayMessages.length - 1]; + if (!lastMessage || !projection.ungroupedMessageIds.has(lastMessage.info.id)) { + return undefined; + } + + return { + kind: 'ungrouped', + key: `msg:${lastMessage.info.id}`, + message: lastMessage, + previousMessage: displayMessages.length > 1 ? displayMessages[displayMessages.length - 2] : undefined, + nextMessage: undefined, + } satisfies RenderEntry; + }, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, streamingTurn]); + + if (trailingStreamingEntry) { + streamPerfCount('ui.message_list.render.streaming'); + } + + const historyEntries = staticRenderEntries; + const allEntries = React.useMemo(() => { + return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries; + }, [historyEntries, trailingStreamingEntry]); const currentUserOrder = React.useMemo(() => { return messages @@ -1136,77 +1388,14 @@ const MessageList = React.forwardRef(({ return userAnimationRef.current.animatedIds.has(message.info.id); }, []); - const onUserAnimationConsumed = React.useCallback(() => { - // Animation plays once via ToolRevealOnMount; no cleanup needed. - // The ref-based animatedIds set is reset on session switch. + const onUserAnimationConsumed = React.useCallback((messageId: string) => { + userAnimationRef.current.animatedIds.delete(messageId); }, []); - const shouldVirtualize = Boolean(resolveScrollContainer()) && stagedEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD; - - const estimateEntrySize = React.useCallback( - (index: number): number => { - const entry = stagedEntries[index]; - if (!entry) { - return 220; - } - if (entry.kind === 'turn') { - const assistantCount = entry.turn.assistantMessages.length; - return Math.min( - TURN_ESTIMATE_MAX_PX, - TURN_ESTIMATE_BASE_PX + assistantCount * TURN_ESTIMATE_PER_ASSISTANT_PX, - ); - } - const role = resolveMessageRole(entry.message); - return role === 'user' ? 100 : 220; - }, - [stagedEntries] - ); - - const virtualizer = useMessageListVirtualizer({ - count: stagedEntries.length, - getScrollElement: resolveScrollContainer, - estimateSize: estimateEntrySize, - overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP, - getItemKey: (index: number) => stagedEntries[index]?.key ?? index, - enabled: shouldVirtualize, - useFlushSync: false, - }); - - const isVirtualRowInRange = React.useCallback( - (row: VirtualItem) => row.index >= 0 && row.index < stagedEntries.length, - [stagedEntries.length], - ); - - const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems().filter(isVirtualRowInRange) : []; - const lastNonEmptyVirtualRowsRef = React.useRef([]); - if (shouldVirtualize && virtualRows.length > 0) { - lastNonEmptyVirtualRowsRef.current = virtualRows; - } else if (!shouldVirtualize && lastNonEmptyVirtualRowsRef.current.length > 0) { - lastNonEmptyVirtualRowsRef.current = []; - } - - const fallbackVirtualRows = shouldVirtualize - ? lastNonEmptyVirtualRowsRef.current.filter(isVirtualRowInRange) - : []; - - const effectiveVirtualRows = shouldVirtualize - ? (virtualRows.length > 0 ? virtualRows : fallbackVirtualRows) - : []; - - const renderVirtualized = shouldVirtualize && effectiveVirtualRows.length > 0; - - const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => { - if (!virtualizer) { - return; - } - const normalizedBehavior: 'auto' | 'smooth' = behavior === 'instant' ? 'auto' : behavior; - virtualizer.scrollToIndex(index, { align: 'start', behavior: normalizedBehavior }); - }, [virtualizer]); - const messageIndexMap = React.useMemo(() => { const indexMap = new Map(); - stagedEntries.forEach((entry, index) => { + allEntries.forEach((entry, index) => { if (entry.kind === 'ungrouped') { indexMap.set(entry.message.info.id, index); return; @@ -1218,17 +1407,17 @@ const MessageList = React.forwardRef(({ }); return indexMap; - }, [stagedEntries]); + }, [allEntries]); const turnIndexMap = React.useMemo(() => { const indexMap = new Map(); - stagedEntries.forEach((entry, index) => { + allEntries.forEach((entry, index) => { if (entry.kind === 'turn') { indexMap.set(entry.turn.turnId, index); } }); return indexMap; - }, [stagedEntries]); + }, [allEntries]); const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => { const container = resolveScrollContainer(); @@ -1256,7 +1445,7 @@ const MessageList = React.forwardRef(({ return true; }, [findMessageElement, resolveScrollContainer]); - React.useLayoutEffect(() => { + React.useEffect(() => { if (!ref) { return; } @@ -1269,21 +1458,9 @@ const MessageList = React.forwardRef(({ return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - const container = resolveScrollContainer(); - if (!container) { - return; - } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); - if (turnElement) { - turnElement.scrollIntoView({ behavior, block: 'start' }); - } - }); - } - return true; + const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length; + if (targetIsTail) { + return false; } const container = resolveScrollContainer(); @@ -1305,25 +1482,9 @@ const MessageList = React.forwardRef(({ return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); - if (scrollMessageElementIntoView(messageId, behavior)) { - return true; - } - if (typeof window !== 'undefined') { - let attempts = 0; - const retry = () => { - attempts += 1; - if (scrollMessageElementIntoView(messageId, behavior)) { - return; - } - if (attempts < 3) { - window.requestAnimationFrame(retry); - } - }; - window.requestAnimationFrame(retry); - } - return true; + const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length; + if (targetIsTail) { + return false; } return scrollMessageElementIntoView(messageId, behavior); @@ -1337,7 +1498,20 @@ const MessageList = React.forwardRef(({ const containerRect = container.getBoundingClientRect(); const nodes: HTMLElement[] = Array.from(container.querySelectorAll('[data-message-id]')); - const firstVisible = nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); + const firstVisible = nodes.find((node) => { + const rect = node.getBoundingClientRect(); + if (rect.bottom <= containerRect.top + 1) { + return false; + } + + if (typeof window === 'undefined') { + return true; + } + + const computed = window.getComputedStyle(node); + const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1; + return !isStuckSticky; + }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; } @@ -1359,15 +1533,10 @@ const MessageList = React.forwardRef(({ return false; } - const index = messageIndexMap.get(anchor.messageId); - if (index === undefined) { + if (!messageIndexMap.has(anchor.messageId)) { return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, 'auto'); - } - const applyAnchor = (): boolean => { const element = findMessageElement(anchor.messageId); if (!element) { @@ -1382,25 +1551,7 @@ const MessageList = React.forwardRef(({ return true; }; - if (applyAnchor()) { - return true; - } - - if (typeof window !== 'undefined') { - let attempts = 0; - const retry = () => { - attempts += 1; - if (applyAnchor()) { - return; - } - if (attempts < 3) { - window.requestAnimationFrame(retry); - } - }; - window.requestAnimationFrame(retry); - } - - return true; + return applyAnchor(); }, }; @@ -1416,9 +1567,9 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, scrollVirtualizerToIndex, shouldVirtualize, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, trailingStreamingEntry, turnIndexMap, ref]); - const disableFadeIn = isLoadingOlder || (renderVirtualized && virtualizer.isScrolling); + const disableFadeIn = false; return (
@@ -1440,56 +1591,10 @@ const MessageList = React.forwardRef(({
)} - {staging.isStaging ? ( -
- - Revealing history… - -
- ) : null} - - {renderVirtualized ? ( -
- {effectiveVirtualRows.map((virtualRow: VirtualItem) => { - const entry = stagedEntries[virtualRow.index]; - if (!entry) { - return null; - } - - return ( -
- -
- ); - })} -
- ) : ( -
+
(({ chatRenderMode={chatRenderMode} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} /> -
- )} + {trailingStreamingEntry ? ( + + ) : null} +
{(questions.length > 0 || permissions.length > 0) && ( @@ -1518,18 +1640,7 @@ const MessageList = React.forwardRef(({ )}
- +
{/* Bottom spacer */} diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index 944a6f30..f1687889 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { getAgentDisplayName } from './mobileControlsUtils'; import { getAgentColor } from '@/lib/agentColors'; @@ -16,8 +17,8 @@ const LONG_PRESS_MS = 500; // NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile export const MobileAgentButton: React.FC = ({ onCycleAgent, onOpenAgentPanel, className }) => { const { currentAgentName, getVisibleAgents } = useConfigStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessionAgentName = useSessionStore((state) => + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionAgentName = useSelectionStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index b3b3360f..e29b27b4 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useSessions, useAllSessionStatuses } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -52,6 +54,7 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useNotificationStore } from '@/sync/notification-store'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; @@ -74,9 +77,10 @@ const normalize = (value: string): string => { function useSessionGrouping( sessions: Session[], - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined + sessionStatus: Record | undefined ) { + const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); + const parentChildMap = React.useMemo(() => { const map = new Map(); const allIds = new Set(sessions.map((s) => s.id)); @@ -91,7 +95,7 @@ function useSessionGrouping( }, [sessions]); const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }, [sessionStatus]); @@ -126,7 +130,7 @@ function useSessionGrouping( topLevel.forEach((session) => { const statusType = getStatusType(session.id); const hasRunning = hasRunningChildren(session.id); - const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false; + const attention = (unseenCounts[session.id] ?? 0) > 0; const enriched: SessionWithStatus = { ...session, @@ -155,28 +159,27 @@ function useSessionGrouping( viewed.sort(sortByUpdated); return [...running, ...viewed]; - }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]); + }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]); const totalRunning = processedSessions.reduce((sum, s) => { const selfRunning = s._statusType !== 'idle' ? 1 : 0; return sum + selfRunning + (s._runningChildrenCount ?? 0); }, 0); - const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length; + const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length; return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; } function useSessionHelpers( agents: Array<{ name: string }>, - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined + sessionStatus: Record | undefined ) { const getSessionAgentName = React.useCallback((session: Session): string => { const agent = (session as { agent?: string }).agent; if (agent) return agent; - const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id); + const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id); if (sessionAgentSelection) return sessionAgentSelection; return agents[0]?.name ?? 'agent'; @@ -189,14 +192,14 @@ function useSessionHelpers( }, []); const isRunning = React.useCallback((sessionId: string): boolean => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; return status?.type === 'busy' || status?.type === 'retry'; }, [sessionStatus]); - // Use server-authoritative attention state instead of local activity state + const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const needsAttention = React.useCallback((sessionId: string): boolean => { - return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false; - }, [sessionAttentionStates]); + return (unseenCounts[sessionId] ?? 0) > 0; + }, [unseenCounts]); return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; } @@ -204,17 +207,16 @@ function useSessionHelpers( // Hook to calculate project status indicators function useProjectStatus( sessions: Session[], - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined, + sessionStatus: Record | undefined, currentSessionId: string | null ) { - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); - const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); + const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }; @@ -241,7 +243,7 @@ function useProjectStatus( let hasUnread = false; for (const dir of dirs) { - const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir); + const list = getSessionsByDirectory(dir); for (const session of list) { if (!session?.id || seen.has(session.id)) { continue; @@ -253,7 +255,7 @@ function useProjectStatus( hasRunning = true; } - if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) { + if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) { hasUnread = true; } @@ -267,7 +269,7 @@ function useProjectStatus( } return { hasRunning, hasUnread }; - }, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]); + }, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]); return projectStatusMap; } @@ -1290,7 +1292,7 @@ function ExpandedView({ const [collapsedHeight, setCollapsedHeight] = React.useState(null); const [hasMeasured, setHasMeasured] = React.useState(false); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); React.useEffect(() => { if (containerRef.current && !hasMeasured && !isExpanded) { @@ -1429,13 +1431,12 @@ export const MobileSessionStatusBar: React.FC = ({ cornerRadius, }) => { const { currentTheme } = useThemeSystem(); - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessionStatus = useSessionStore((state) => state.sessionStatus); - const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const getContextUsage = useSessionStore((state) => state.getContextUsage); + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionStatus = useAllSessionStatuses(); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const agents = useConfigStore((state) => state.agents); const { getCurrentModel } = useConfigStore(); const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); @@ -1452,9 +1453,9 @@ export const MobileSessionStatusBar: React.FC = ({ // Directory store const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates); - const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates); - const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId); + const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus); + const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus); + const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); const currentSession = sessions.find((s) => s.id === currentSessionId); const currentSessionTitle = currentSession diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index dfbdade3..97422066 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -47,7 +47,10 @@ import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useDirectorySync, useSessionMessages } from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; @@ -314,20 +317,23 @@ export const ModelControls: React.FC = ({ const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession); + const sync = useSync(); + const { - currentSessionId, - messages, + getSessionModelSelection, + saveSessionModelSelection, saveSessionAgentSelection, saveAgentModelForSession, getAgentModelForSession, saveAgentModelVariantForSession, getAgentModelVariantForSession, - analyzeAndSaveExternalSessionChoices, - } = useSessionStore(); + } = useSelectionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); - const sessionSavedAgentName = useContextStore((state) => + const sessionSavedAgentName = useSelectionStore((state) => currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null ); @@ -563,31 +569,45 @@ export const ModelControls: React.FC = ({ ]; const prevAgentNameRef = React.useRef(undefined); + const latestLoadedUserChoiceRestoreRef = React.useRef(null); - const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1; + const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; + const hasCurrentSessionMessagesEntry = useDirectorySync( + React.useCallback( + (state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false), + [currentSessionId], + ), + currentSessionDirectory ?? undefined, + ); + const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); + const latestLoadedUserChoice = React.useMemo(() => { + for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) { + const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & { + model?: { providerID?: string; modelID?: string }; + variant?: string; + mode?: string; + }; + if (message.role !== 'user') { + continue; + } - const sessionInitializationRef = React.useRef<{ - sessionId: string; - resolved: boolean; - inFlight: boolean; - } | null>(null); + const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined; + const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined; + const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined); + const variant = typeof message.variant === 'string' && message.variant.trim().length > 0 + ? message.variant + : undefined; - // If we have an explicit per-session agent selection (eg. server-injected mode switch), - // treat the session as resolved and don't run inference/fallback that could cause flicker. - React.useEffect(() => { - if (!currentSessionId) { - return; + return { id: message.id, agent, providerID, modelID, variant }; } - const refState = sessionInitializationRef.current; - if (!refState || refState.sessionId !== currentSessionId) { - return; - } - - if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) { - refState.resolved = true; - refState.inFlight = false; - } - }, [agents, currentSessionId, sessionSavedAgentName]); + return null; + }, [currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { @@ -606,21 +626,93 @@ export const ModelControls: React.FC = ({ return 'model-missing'; } + const providerMatches = currentProviderId === providerId; + const modelMatches = currentModelId === modelId; + if (providerMatches && modelMatches) { + return 'applied'; + } + setProvider(providerId); setModel(modelId); - if (currentSessionId && agentName) { - saveAgentModelForSession(currentSessionId, agentName, providerId, modelId); + if (currentSessionId) { + saveSessionModelSelection(currentSessionId, providerId, modelId); + if (agentName) { + saveAgentModelForSession(currentSessionId, agentName, providerId, modelId); + } } return 'applied'; }, - [providers, setProvider, setModel, currentSessionId, saveAgentModelForSession], + [providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection], ); React.useEffect(() => { if (!currentSessionId) { - sessionInitializationRef.current = null; + latestLoadedUserChoiceRestoreRef.current = null; + return; + } + + if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) { + return; + } + + const restoreKey = [ + currentSessionId, + latestLoadedUserChoice.id, + latestLoadedUserChoice.agent ?? '', + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.variant ?? '', + ].join('|'); + + if (latestLoadedUserChoiceRestoreRef.current === restoreKey) { + return; + } + + if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { + setAgent(latestLoadedUserChoice.agent); + } + + const applyResult = tryApplyModelSelection( + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.agent || currentAgentName || undefined, + ); + if (applyResult !== 'applied') { + return; + } + + if (latestLoadedUserChoice.agent) { + saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent); + saveAgentModelVariantForSession( + currentSessionId, + latestLoadedUserChoice.agent, + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.variant, + ); + } + saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID); + latestLoadedUserChoiceRestoreRef.current = restoreKey; + + }, [ + currentSessionId, + currentAgentName, + contextHydrated, + providers, + hasCurrentSessionMessagesEntry, + latestLoadedUserChoice, + setAgent, + tryApplyModelSelection, + saveSessionAgentSelection, + saveAgentModelVariantForSession, + saveSessionModelSelection, + ]); + + React.useEffect(() => { + if (!currentSessionId) { + latestLoadedUserChoiceRestoreRef.current = null; return; } @@ -628,31 +720,10 @@ export const ModelControls: React.FC = ({ return; } - if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) { - sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false }; - } - - const state = sessionInitializationRef.current; - if (!state || state.resolved || state.inFlight) { - return; - } - - let isCancelled = false; - - const finalize = () => { - if (isCancelled) { - return; - } - const refState = sessionInitializationRef.current; - if (refState && refState.sessionId === currentSessionId) { - refState.resolved = true; - refState.inFlight = false; - } - }; - const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => { + const savedSessionModel = getSessionModelSelection(currentSessionId); const savedAgentName = currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) : null; if (savedAgentName) { if (currentAgentName !== savedAgentName) { @@ -668,9 +739,17 @@ export const ModelControls: React.FC = ({ if (result === 'provider-missing') { return 'waiting'; } - } else { + } + } + + if (savedSessionModel) { + const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined); + if (result === 'applied') { return 'resolved'; } + if (result === 'provider-missing') { + return 'waiting'; + } } for (const agent of agents) { @@ -683,7 +762,7 @@ export const ModelControls: React.FC = ({ setAgent(agent.name); } - const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; + const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; if (!existingSelection) { saveSessionAgentSelection(currentSessionId, agent.name); } @@ -705,14 +784,14 @@ export const ModelControls: React.FC = ({ } const existingSelection = currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null; // If we already have a valid agent selected (often from server-injected mode switch), // don't override it with a fallback. const preferred = (currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null) || currentAgentName; if (preferred && agents.some((agent) => agent.name === preferred)) { @@ -740,174 +819,38 @@ export const ModelControls: React.FC = ({ } }; - const resolveSessionPreferences = async () => { - try { - const savedOutcome = applySavedSelections(); - if (savedOutcome === 'resolved') { - finalize(); - return; - } - if (savedOutcome === 'waiting') { - return; - } + const savedOutcome = applySavedSelections(); + if (savedOutcome === 'resolved' || savedOutcome === 'waiting') { + return; + } - if (currentSessionMessageCount === -1) { - return; - } - - if (currentSessionMessageCount > 0) { - state.inFlight = true; - try { - const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents); - if (isCancelled) { - return; - } - - if (discoveredChoices.size > 0) { - let latestAgent: string | null = null; - let latestTimestamp = -Infinity; - - for (const [agentName, choice] of discoveredChoices) { - if (choice.timestamp > latestTimestamp) { - latestTimestamp = choice.timestamp; - latestAgent = agentName; - } - } - - if (latestAgent) { - // If server/user already selected an agent for this session, don't override - // with heuristic inference mid-stream. - const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; - if (latestSaved && latestSaved !== latestAgent) { - finalize(); - return; - } - - if (!latestSaved) { - saveSessionAgentSelection(currentSessionId, latestAgent); - } - if (currentAgentName !== latestAgent) { - setAgent(latestAgent); - } - - const latestChoice = discoveredChoices.get(latestAgent); - if (latestChoice) { - const applyResult = tryApplyModelSelection( - latestChoice.providerId, - latestChoice.modelId, - latestAgent, - ); - - if (applyResult === 'applied') { - finalize(); - return; - } - - if (applyResult === 'provider-missing') { - return; - } - } else { - finalize(); - return; - } - } - } - } catch (error) { - if (!isCancelled) { - console.error('[ModelControls] Error resolving session from messages:', error); - } - } finally { - const refState = sessionInitializationRef.current; - if (!isCancelled && refState && refState.sessionId === currentSessionId) { - refState.inFlight = false; - } - } - } - - if (isCancelled) { - return; - } - - applyFallbackAgent(); - finalize(); - } catch (error) { - if (!isCancelled) { - console.error('[ModelControls] Error in session switch:', error); - } + if (!hasCurrentSessionMessagesEntry) { + if (!sync.isLoading(currentSessionId)) { + void sync.syncSession(currentSessionId); } - }; + return; + } - resolveSessionPreferences(); + if (latestLoadedUserChoice) { + return; + } - return () => { - isCancelled = true; - }; + applyFallbackAgent(); }, [ currentSessionId, - currentSessionMessageCount, + hasCurrentSessionMessagesEntry, + latestLoadedUserChoice, agents, primaryAgents, currentAgentName, + getSessionModelSelection, getAgentModelForSession, setAgent, tryApplyModelSelection, - analyzeAndSaveExternalSessionChoices, saveSessionAgentSelection, contextHydrated, providers, - sessionSavedAgentName, - ]); - - React.useEffect(() => { - if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) { - return; - } - - const preferredAgent = sessionSavedAgentName || currentAgentName; - if (!preferredAgent) { - return; - } - - const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent); - if (!preferredSelection) { - return; - } - - const provider = providers.find(p => p.id === preferredSelection.providerId); - if (!provider) { - return; - } - - const modelExists = Array.isArray(provider.models) - ? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId) - : false; - if (!modelExists) { - return; - } - - const providerMatches = currentProviderId === preferredSelection.providerId; - const modelMatches = currentModelId === preferredSelection.modelId; - if (providerMatches && modelMatches) { - return; - } - - if (preferredAgent !== currentAgentName) { - setAgent(preferredAgent); - } - - tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent); - }, [ - contextHydrated, - currentSessionId, - currentAgentName, - currentProviderId, - currentModelId, - providers, - agents, - getAgentModelForSession, - tryApplyModelSelection, - setAgent, - sessionSavedAgentName, + sync, ]); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index 2fee9c52..0bebd5c5 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { PermissionRequest, PermissionResponse } from '@/types/permission'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; +import * as sessionActions from '@/sync/session-actions'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -62,15 +64,14 @@ export const PermissionCard: React.FC = ({ }) => { const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); - const { respondToPermission } = useSessionStore(); - const isFromSubagent = useSessionStore( - React.useCallback((state) => { - const currentSessionId = state.currentSessionId; - if (!currentSessionId || permission.sessionID === currentSessionId) return false; - const sourceSession = state.sessions.find((session) => session.id === permission.sessionID); - return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); - }, [permission.sessionID]) - ); + const respondToPermission = sessionActions.respondToPermission;; + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isFromSubagent = React.useMemo(() => { + if (!currentSessionId || permission.sessionID === currentSessionId) return false; + const sourceSession = sessions.find((session) => session.id === permission.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [permission.sessionID, currentSessionId, sessions]); const { currentTheme } = useThemeSystem(); const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); diff --git a/packages/ui/src/components/chat/PermissionRequest.tsx b/packages/ui/src/components/chat/PermissionRequest.tsx index ab4fad33..402fb9cb 100644 --- a/packages/ui/src/components/chat/PermissionRequest.tsx +++ b/packages/ui/src/components/chat/PermissionRequest.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission'; -import { useSessionStore } from '@/stores/useSessionStore'; +import * as sessionActions from '@/sync/session-actions'; interface PermissionRequestProps { permission: PermissionRequestPayload; @@ -15,7 +15,7 @@ export const PermissionRequest: React.FC = ({ }) => { const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); - const { respondToPermission } = useSessionStore(); + const respondToPermission = sessionActions.respondToPermission;; const handleResponse = async (response: PermissionResponse) => { setIsResponding(true); diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index fa6a670d..d6feb365 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -4,7 +4,9 @@ import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import type { QuestionRequest } from '@/types/question'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; +import * as sessionActions from '@/sync/session-actions'; interface QuestionCardProps { question: QuestionRequest; @@ -14,15 +16,15 @@ type TabKey = string; const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ question }) => { - const { respondToQuestion, rejectQuestion } = useSessionStore(); - const isFromSubagent = useSessionStore( - React.useCallback((state) => { - const currentSessionId = state.currentSessionId; - if (!currentSessionId || question.sessionID === currentSessionId) return false; - const sourceSession = state.sessions.find((session) => session.id === question.sessionID); - return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); - }, [question.sessionID]) - ); + const respondToQuestion = sessionActions.respondToQuestion; + const rejectQuestion = sessionActions.rejectQuestion;; + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isFromSubagent = React.useMemo(() => { + if (!currentSessionId || question.sessionID === currentSessionId) return false; + const sourceSession = sessions.find((session) => session.id === question.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [question.sessionID, currentSessionId, sessions]); const [activeTab, setActiveTab] = React.useState('0'); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index b36ca389..31fd5cce 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -1,8 +1,8 @@ import React, { memo } from 'react'; import { RiCloseLine, RiMessage2Line } from '@remixicon/react'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useFileStore } from '@/stores/fileStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; interface QueuedMessageChipProps { message: QueuedMessage; @@ -67,7 +67,7 @@ interface QueuedMessageChipsProps { const EMPTY_QUEUE: QueuedMessage[] = []; export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { @@ -84,10 +84,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro const popped = popToInput(currentSessionId, message.id); if (popped) { - // Restore attachments to file store if any if (popped.attachments && popped.attachments.length > 0) { - const currentAttachments = useFileStore.getState().attachedFiles; - useFileStore.setState({ + const currentAttachments = useInputStore.getState().attachedFiles; + useInputStore.setState({ attachedFiles: [...currentAttachments, ...popped.attachments] }); } diff --git a/packages/ui/src/components/chat/StatusChip.tsx b/packages/ui/src/components/chat/StatusChip.tsx index e1d26f21..6283ba71 100644 --- a/packages/ui/src/components/chat/StatusChip.tsx +++ b/packages/ui/src/components/chat/StatusChip.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useContextStore } from '@/stores/contextStore'; import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils'; @@ -19,7 +19,7 @@ export const StatusChip: React.FC = ({ onClick, className }) => getCurrentModelVariants, getVisibleAgents, } = useConfigStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index bf673941..2a8a2eb6 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { RiArrowDownSLine, RiArrowUpDoubleLine, @@ -9,8 +10,13 @@ import { RiTimeLine, } from "@remixicon/react"; import { cn } from "@/lib/utils"; -import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore"; -import { useSessionStore } from "@/stores/useSessionStore"; +import { useDirectorySync } from "@/sync/sync-context"; +import type { Todo } from "@opencode-ai/sdk/v2/client"; + +// Compat aliases for old TodoItem shape +type TodoItem = Todo & { id?: string }; +type TodoStatus = string; +type TodoPriority = string; import { useUIStore } from "@/stores/useUIStore"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { isVSCodeRuntime } from "@/lib/desktop"; @@ -146,21 +152,15 @@ export const StatusRow: React.FC = ({ agentName, }) => { const [isExpanded, setIsExpanded] = React.useState(false); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const todos = useTodoStore((state) => - currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const todosRecord = useDirectorySync((state) => state.todo); + const todos: TodoItem[] = React.useMemo( + () => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS), + [todosRecord, currentSessionId], ); - const loadTodos = useTodoStore((state) => state.loadTodos); const { isMobile } = useUIStore(); const isCompact = isMobile || isVSCodeRuntime(); - // Load todos when session changes - React.useEffect(() => { - if (currentSessionId) { - void loadTodos(currentSessionId); - } - }, [currentSessionId, loadTodos]); - // Filter out cancelled todos for display and keep original order. // This prevents items from jumping around when status changes. const visibleTodos = React.useMemo(() => { @@ -313,8 +313,8 @@ export const StatusRow: React.FC = ({ {/* Todo list */}
- {visibleTodos.map((todo) => ( - + {visibleTodos.map((todo, index) => ( + ))}
diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx new file mode 100644 index 00000000..9c7da1f5 --- /dev/null +++ b/packages/ui/src/components/chat/StatusRowContainer.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useAssistantStatus } from '@/hooks/useAssistantStatus'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { StatusRow } from './StatusRow'; + +/** + * Self-contained wrapper — subscribes to assistant status internally + * so MessageList doesn't re-render on every streaming part delta. + */ +export const StatusRowContainer: React.FC = React.memo(() => { + const { working } = useAssistantStatus(); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + + return ( + + ); +}); + +StatusRowContainer.displayName = 'StatusRowContainer'; diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index bbb1eabe..5db0f35a 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -7,8 +7,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessageRecords } from '@/sync/sync-context'; import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { Part } from '@opencode-ai/sdk/v2'; @@ -44,13 +44,10 @@ export const TimelineDialog: React.FC = ({ onScrollByTurnOffset, onResumeToLatest, }) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const messages = useMessageStore((state) => - currentSessionId ? state.messages.get(currentSessionId) || [] : [] - ); - const revertToMessage = useSessionStore((state) => state.revertToMessage); - const forkFromMessage = useSessionStore((state) => state.forkFromMessage); - const loadSessions = useSessionStore((state) => state.loadSessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const messages = useSessionMessageRecords(currentSessionId ?? ''); + const revertToMessage = useSessionUIStore((state) => state.revertToMessage); + const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage); const [forkingMessageId, setForkingMessageId] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(''); @@ -78,7 +75,6 @@ export const TimelineDialog: React.FC = ({ setForkingMessageId(messageId); try { await forkFromMessage(currentSessionId, messageId); - await loadSessions(); onOpenChange(false); } finally { setForkingMessageId(null); diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx index 1de252c1..e3d64d5a 100644 --- a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx +++ b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx @@ -3,7 +3,8 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; @@ -55,11 +56,8 @@ export const UnifiedControlsDrawer: React.FC = ({ } = useConfigStore(); const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore(); const { recentModelsList } = useModelLists(); - const { - currentSessionId, - saveAgentModelForSession, - saveAgentModelVariantForSession, - } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore(); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/components/TurnList.tsx b/packages/ui/src/components/chat/components/TurnList.tsx index afaf4671..be2fd0dd 100644 --- a/packages/ui/src/components/chat/components/TurnList.tsx +++ b/packages/ui/src/components/chat/components/TurnList.tsx @@ -10,7 +10,18 @@ interface TurnListProps { } const TurnList = ({ entries, renderEntry }: TurnListProps): React.ReactElement => { - return <>{entries.map((entry) => renderEntry(entry))}; + return ( + <> + {entries.map((entry) => ( +
+ {renderEntry(entry)} +
+ ))} + + ); }; export default React.memo(TurnList) as typeof TurnList; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 5899be54..57ec4420 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -141,7 +141,7 @@ export const useChatTimelineController = ({ historyMetaRef.current = historyMeta; }, [historyMeta]); - React.useEffect(() => { + React.useLayoutEffect(() => { if (initializedSessionRef.current === sessionId) { return; } @@ -153,11 +153,11 @@ export const useChatTimelineController = ({ previousTurnCountRef.current = turnWindowModel.turnCount; }, [sessionId, turnWindowModel.turnCount]); - React.useEffect(() => { + React.useLayoutEffect(() => { setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount)); }, [turnWindowModel.turnCount]); - React.useEffect(() => { + React.useLayoutEffect(() => { const previousTurnCount = previousTurnCountRef.current; const nextTurnCount = turnWindowModel.turnCount; if (previousTurnCount === nextTurnCount) { @@ -180,6 +180,42 @@ export const useChatTimelineController = ({ return windowMessagesByTurn(messages, turnWindowModel, turnStart); }, [messages, turnStart, turnWindowModel]); + // --- Synchronous scroll compensation for load-more / reveal --- + // fetchOlderHistory and revealBufferedTurns store a snapshot here + // before triggering the state change. useLayoutEffect consumes it + // after React commits new DOM — before the browser paints. + const prePrependScrollRef = React.useRef<{ + height: number; + top: number; + anchor: ViewportAnchor | null; + } | null>(null); + + React.useLayoutEffect(() => { + const snap = prePrependScrollRef.current; + const container = scrollRef.current; + if (!snap || !container) return; + prePrependScrollRef.current = null; + + // Try anchor-based restoration first (pixel-perfect) + if (snap.anchor) { + const anchorEl = container.querySelector( + `[data-message-id="${snap.anchor.messageId}"]`, + ); + if (anchorEl) { + const containerRect = container.getBoundingClientRect(); + const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top; + container.scrollTop += anchorTop - snap.anchor.offsetTop; + return; + } + } + + // Fallback: height-delta compensation + const delta = container.scrollHeight - snap.height; + if (delta > 0) { + container.scrollTop = snap.top + delta; + } + }, [renderedMessages, scrollRef]); + const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { return messageListRef.current?.captureViewportAnchor() ?? null; }, [messageListRef]); @@ -188,35 +224,19 @@ export const useChatTimelineController = ({ return messageListRef.current?.restoreViewportAnchor(anchor) ?? false; }, [messageListRef]); - const restoreViewportWithFallback = React.useCallback((input: { - anchor: ViewportAnchor | null; - previousHeight: number | null; - previousTop: number | null; - }) => { - const container = scrollRef.current; - if (input.anchor && restoreViewportAnchor(input.anchor)) { - return; - } - - if (!container || input.previousHeight === null || input.previousTop === null) { - return; - } - - const heightDelta = container.scrollHeight - input.previousHeight; - if (heightDelta !== 0) { - container.scrollTop = input.previousTop + heightDelta; - } - }, [restoreViewportAnchor, scrollRef]); - const revealBufferedTurns = React.useCallback(async (): Promise => { if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) { return false; } - const anchor = captureViewportAnchor(); const container = scrollRef.current; - const previousHeight = container?.scrollHeight ?? null; - const previousTop = container?.scrollTop ?? null; + if (container) { + prePrependScrollRef.current = { + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + }; + } setPendingRevealWork(true); setTurnStart((current) => { @@ -225,14 +245,9 @@ export const useChatTimelineController = ({ }); await waitForFrames(1); - restoreViewportWithFallback({ - anchor, - previousHeight, - previousTop, - }); setPendingRevealWork(false); return true; - }, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]); + }, [captureViewportAnchor, scrollRef]); const fetchOlderHistory = React.useCallback(async (input: { preserveViewport: boolean; @@ -244,16 +259,22 @@ export const useChatTimelineController = ({ return false; } - const anchor = input.preserveViewport ? captureViewportAnchor() : null; const container = scrollRef.current; - const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null; - const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null; const beforeMessages = messagesRef.current; const beforeMessageCount = beforeMessages.length; const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null; const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES; - setPendingRevealWork(true); + // Store scroll snapshot BEFORE the fetch so useLayoutEffect can + // compensate synchronously when React commits the new messages. + if (input.preserveViewport && container) { + prePrependScrollRef.current = { + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + }; + } + setIsLoadingOlder(true); try { @@ -274,20 +295,11 @@ export const useChatTimelineController = ({ && typeof afterOldestMessageId === 'string' && beforeOldestMessageId !== afterOldestMessageId); - if (input.preserveViewport) { - restoreViewportWithFallback({ - anchor, - previousHeight, - previousTop, - }); - } - return historyGrew || afterLimit > beforeLimit; } finally { setIsLoadingOlder(false); - setPendingRevealWork(false); } - }, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]); + }, [captureViewportAnchor, loadMoreMessages, scrollRef]); const loadEarlier = React.useCallback(async () => { if (await revealBufferedTurns()) { diff --git a/packages/ui/src/components/chat/hooks/useTurnRecords.ts b/packages/ui/src/components/chat/hooks/useTurnRecords.ts index ddbbd17f..b1c1da9d 100644 --- a/packages/ui/src/components/chat/hooks/useTurnRecords.ts +++ b/packages/ui/src/components/chat/hooks/useTurnRecords.ts @@ -1,9 +1,11 @@ import React from 'react'; import { projectTurnRecords } from '../lib/turns/projectTurnRecords'; import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection'; -import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types'; +import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types'; +import { streamPerfMeasure } from '@/stores/utils/streamDebug'; interface UseTurnRecordsOptions { + sessionKey?: string; showTextJustificationActivity: boolean; } @@ -18,33 +20,59 @@ export const useTurnRecords = ( options: UseTurnRecordsOptions, ): TurnRecordsResult => { const previousProjectionRef = React.useRef(null); + const staticTurnsRef = React.useRef([]); + const streamingTurnRef = React.useRef(undefined); React.useEffect(() => { previousProjectionRef.current = null; - }, [options.showTextJustificationActivity]); + staticTurnsRef.current = []; + streamingTurnRef.current = undefined; + }, [options.sessionKey, options.showTextJustificationActivity]); const projection = React.useMemo(() => { - const rawProjection = projectTurnRecords(messages, { - previousProjection: previousProjectionRef.current, - showTextJustificationActivity: options.showTextJustificationActivity, + return streamPerfMeasure('ui.turns.projection_ms', () => { + const rawProjection = projectTurnRecords(messages, { + previousProjection: previousProjectionRef.current, + showTextJustificationActivity: options.showTextJustificationActivity, + }); + const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current); + previousProjectionRef.current = stabilizedProjection; + return stabilizedProjection; }); - const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current); - previousProjectionRef.current = stabilizedProjection; - return stabilizedProjection; }, [messages, options.showTextJustificationActivity]); const staticTurns = React.useMemo(() => { - if (projection.turns.length <= 1) { - return []; + const nextStatic = projection.turns.length <= 1 + ? [] + : projection.turns.slice(0, -1); + const previousStatic = staticTurnsRef.current; + + if (previousStatic.length === nextStatic.length) { + let isSame = true; + for (let index = 0; index < nextStatic.length; index += 1) { + if (previousStatic[index] !== nextStatic[index]) { + isSame = false; + break; + } + } + if (isSame) { + return previousStatic; + } } - return projection.turns.slice(0, -1); + + staticTurnsRef.current = nextStatic; + return nextStatic; }, [projection.turns]); const streamingTurn = React.useMemo(() => { - if (projection.turns.length === 0) { - return undefined; + const nextStreamingTurn = projection.turns.length === 0 + ? undefined + : projection.turns[projection.turns.length - 1]; + if (streamingTurnRef.current === nextStreamingTurn) { + return streamingTurnRef.current; } - return projection.turns[projection.turns.length - 1]; + streamingTurnRef.current = nextStreamingTurn; + return nextStreamingTurn; }, [projection.turns]); return { diff --git a/packages/ui/src/components/chat/lib/turns/historySignals.ts b/packages/ui/src/components/chat/lib/turns/historySignals.ts index f3f70520..d9365ca1 100644 --- a/packages/ui/src/components/chat/lib/turns/historySignals.ts +++ b/packages/ui/src/components/chat/lib/turns/historySignals.ts @@ -1,4 +1,4 @@ -import type { SessionMemoryState } from '@/stores/types/sessionTypes'; +import type { SessionMemoryState } from '@/sync/viewport-store'; export interface TurnHistorySignalsInput { memoryState: SessionMemoryState | null; diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index f83a2677..585d621c 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit }); let firstWithAny: string | undefined; - let cumulative = 0; for (const message of input.assistantMessages) { const count = countByMessage.get(message.info.id) ?? 0; if (count > 0 && !firstWithAny) { firstWithAny = message.info.id; } - cumulative += count; - if (cumulative >= 2) { - return message.info.id; - } } return firstWithAny; diff --git a/packages/ui/src/components/chat/lib/turns/stageTurns.ts b/packages/ui/src/components/chat/lib/turns/stageTurns.ts index 2f88a8ac..c5e7807c 100644 --- a/packages/ui/src/components/chat/lib/turns/stageTurns.ts +++ b/packages/ui/src/components/chat/lib/turns/stageTurns.ts @@ -20,8 +20,8 @@ export interface StageTurnsResult { } const DEFAULT_STAGE_CONFIG: TurnStageConfig = { - init: 1, - batch: 3, + init: 10, + batch: 8, }; export const getInitialStageCount = (total: number, config: TurnStageConfig): number => { diff --git a/packages/ui/src/components/chat/lib/turns/types.ts b/packages/ui/src/components/chat/lib/turns/types.ts index 419f980a..e2024098 100644 --- a/packages/ui/src/components/chat/lib/turns/types.ts +++ b/packages/ui/src/components/chat/lib/turns/types.ts @@ -105,6 +105,7 @@ export type Turn = Pick = ({ part }) => { const [expanded, setExpanded] = React.useState(false); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const description = typeof part.description === 'string' ? part.description.trim() : ''; const command = typeof part.command === 'string' ? part.command.trim() : ''; @@ -254,6 +255,7 @@ const formatTurnDuration = (durationMs: number): string => { interface MessageBodyProps { + sessionId?: string; messageId: string; parts: Part[]; isUser: boolean; @@ -562,6 +564,7 @@ const UserMessageBody: React.FC<{ }; const AssistantMessageBody: React.FC> = ({ + sessionId, messageId, parts, isMessageCompleted, @@ -696,7 +699,7 @@ const AssistantMessageBody: React.FC> = ({ return visibleParts.filter((part) => part.type === 'text'); }, [visibleParts]); - const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); + const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const isSortedRenderMode = chatRenderMode === 'sorted'; @@ -1065,6 +1068,8 @@ const AssistantMessageBody: React.FC> = ({ return all.filter((segment) => segment.anchorMessageId === messageId); }, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]); + const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0; + const activityByPart = React.useMemo(() => { const byRef = new Map(); const byId = new Map(); @@ -1092,9 +1097,14 @@ const AssistantMessageBody: React.FC> = ({ }, [activityPartsForTurn]); const toggleActivityGroup = turnGroupingContext?.toggleGroup; + const isActivityOwnerMessage = !isSortedRenderMode + || !turnGroupingContext?.activityOwnerMessageId + || turnGroupingContext.activityOwnerMessageId === messageId + || hasAnchoredActivitySegments; const shouldRenderActivityGroup = isSortedRenderMode - && activityGroupSegmentsForMessage.length > 0 + && isActivityOwnerMessage + && hasAnchoredActivitySegments && Boolean(toggleActivityGroup); @@ -1155,6 +1165,7 @@ const AssistantMessageBody: React.FC> = ({ > = ({ > = ({ const toolPart = part as ToolPartType; const toolName = toolPart.tool?.toLowerCase() ?? ''; + if (isSortedRenderMode && !isActivityOwnerMessage) { + i += 1; + continue; + } + const activity = activityByPart.get(part); - if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) { + if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) { i += 1; continue; } @@ -1279,8 +1296,10 @@ const AssistantMessageBody: React.FC> = ({ expandedTools, hasStopFinish, isMobile, + isActivityOwnerMessage, isSortedRenderMode, messageId, + sessionId, onContentChange, onShowPopup, onToggleTool, @@ -1525,4 +1544,37 @@ const MessageBody: React.FC = ({ isUser, ...props }) => { return ; }; -export default React.memo(MessageBody); +export default React.memo(MessageBody, (prev, next) => { + return prev.sessionId === next.sessionId + && prev.messageId === next.messageId + && prev.isUser === next.isUser + && areRenderRelevantPartsEqual(prev.parts, next.parts) + && prev.isMessageCompleted === next.isMessageCompleted + && prev.messageFinish === next.messageFinish + && prev.messageCompletedAt === next.messageCompletedAt + && prev.messageCreatedAt === next.messageCreatedAt + && prev.syntaxTheme === next.syntaxTheme + && prev.isMobile === next.isMobile + && prev.hasTouchInput === next.hasTouchInput + && prev.copiedCode === next.copiedCode + && prev.expandedTools === next.expandedTools + && prev.streamPhase === next.streamPhase + && prev.allowAnimation === next.allowAnimation + && prev.shouldShowHeader === next.shouldShowHeader + && prev.hasTextContent === next.hasTextContent + && prev.copiedMessage === next.copiedMessage + && prev.showReasoningTraces === next.showReasoningTraces + && prev.agentMention === next.agentMention + && prev.turnGroupingContext === next.turnGroupingContext + && prev.errorMessage === next.errorMessage + && prev.userActionsMode === next.userActionsMode + && prev.stickyUserHeaderEnabled === next.stickyUserHeaderEnabled + && prev.onCopyCode === next.onCopyCode + && prev.onToggleTool === next.onToggleTool + && prev.onShowPopup === next.onShowPopup + && prev.onContentChange === next.onContentChange + && prev.onCopyMessage === next.onCopyMessage + && prev.onAuxiliaryContentComplete === next.onAuxiliaryContentComplete + && prev.onRevert === next.onRevert + && prev.onFork === next.onFork; +}); diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 7c19ebb1..bb93cdf1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; @@ -196,8 +197,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); - const createSession = useSessionStore((state) => state.createSession); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const createSession = useSessionUIStore((state) => state.createSession); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index d10fd3a3..ee9c0393 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -5,11 +5,13 @@ import type { StreamPhase } from '../types'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; +import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } }; interface AssistantTextPartProps { part: Part; + sessionId?: string; messageId: string; streamPhase: StreamPhase; chatRenderMode?: 'sorted' | 'live'; @@ -22,6 +24,8 @@ const AssistantTextPart: React.FC = ({ streamPhase, chatRenderMode = 'live', }) => { + // Use part directly from props — parent provides the latest version from the store. + // No store subscription here to avoid re-render cascade from unrelated delta events. const partWithText = part as PartWithText; const rawText = typeof partWithText.text === 'string' ? partWithText.text : ''; const contentText = typeof partWithText.content === 'string' ? partWithText.content : ''; @@ -33,6 +37,11 @@ const AssistantTextPart: React.FC = ({ const isCooldownPhase = streamPhase === 'cooldown'; const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase); + streamPerfCount('ui.assistant_text_part.render'); + if (isStreaming) { + streamPerfCount('ui.assistant_text_part.render.streaming'); + } + const throttledTextContent = useStreamingTextThrottle({ text: textContent, isStreaming, @@ -45,32 +54,7 @@ const AssistantTextPart: React.FC = ({ isStreaming, }); - const lastDisplayLengthRef = React.useRef(0); - React.useEffect(() => { - if (!isStreaming || typeof window === 'undefined') { - lastDisplayLengthRef.current = displayTextContent.length; - return; - } - const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1'; - if (!debugEnabled) { - lastDisplayLengthRef.current = displayTextContent.length; - return; - } - if (displayTextContent.length < lastDisplayLengthRef.current) { - console.info('[STREAM-TRACE] render_shrink', { - messageId, - partId: part.id, - rawTextLen: rawText.length, - contentLen: contentText.length, - valueLen: valueText.length, - chosenLen: textContent.length, - throttledLen: throttledTextContent.length, - displayLen: displayTextContent.length, - prevDisplayLen: lastDisplayLengthRef.current, - }); - } - lastDisplayLengthRef.current = displayTextContent.length; - }, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]); + streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length); const time = partWithText.time; const isFinalized = Boolean(time && typeof time.end !== 'undefined'); @@ -105,4 +89,4 @@ const AssistantTextPart: React.FC = ({ ); }; -export default AssistantTextPart; +export default React.memo(AssistantTextPart); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index bfd40187..75f23934 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -8,6 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useUIStore } from '@/stores/useUIStore'; import { useDurationTickerNow } from './useDurationTicker'; import { MarkdownRenderer } from '../../MarkdownRenderer'; +import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; @@ -201,16 +202,21 @@ const ReasoningPart: React.FC = ({ const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number'; + const throttledText = useStreamingTextThrottle({ + text: textContent, + isStreaming, + identityKey: `${messageId}:${part.id ?? 'reasoning'}`, + }); // Show reasoning even if time.end isn't set yet (during streaming) // Only hide if there's no text content - if (!textContent || textContent.trim().length === 0) { + if (!throttledText || throttledText.trim().length === 0) { return null; } return ( { if (typeof value !== 'string') { return undefined; @@ -844,7 +844,7 @@ const TaskToolSummary: React.FC<{ animateTailText?: boolean; isActive?: boolean; }> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const displayEntries = entries; @@ -1674,14 +1674,7 @@ const ToolPart: React.FC = ({ return readTaskSessionIdFromOutput(taskOutputString); }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]); - const childSessionMessages = useSessionStore( - React.useCallback((store) => { - if (!taskSessionId) { - return EMPTY_SESSION_MESSAGES; - } - return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES; - }, [taskSessionId]) - ); + const childSessionMessages = useSessionMessageRecords(taskSessionId ?? ''); const metadataTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { @@ -1901,7 +1894,20 @@ const ToolPart: React.FC = ({ taskPollLastSignatureRef.current = nextSignature; taskPollNoChangeCountRef.current = 0; - useSessionStore.getState().syncMessages(taskSessionId, messages); + // Inject fetched subagent messages into sync child store + const childStores = getSyncChildStores(); + const dir = getSyncDirectory(); + childStores.update(dir, (prev) => { + const records = messages as SessionMessageWithParts[]; + const partPatch: Record = { ...prev.part }; + for (const rec of records) { + partPatch[rec.info.id] = rec.parts; + } + return { + message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] }, + part: partPatch, + }; + }); } catch { // Ignore transient subagent fetch errors. } finally { diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts new file mode 100644 index 00000000..b198c98c --- /dev/null +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -0,0 +1,115 @@ +import type { Message, Part } from '@opencode-ai/sdk/v2'; + +type MessageRecord = { + info: Message; + parts: Part[]; +}; + +const readPartId = (part: Part | undefined): string | null => { + if (!part) return null; + const candidate = (part as { id?: unknown }).id; + return typeof candidate === 'string' && candidate.length > 0 ? candidate : null; +}; + +const readToolStatus = (part: Part | undefined): string | null => { + const status = (part as { state?: { status?: unknown } } | undefined)?.state?.status; + return typeof status === 'string' ? status : null; +}; + +const readPartTime = (part: Part | undefined) => { + const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time; + return { + start: typeof time?.start === 'number' ? time.start : null, + end: typeof time?.end === 'number' ? time.end : null, + }; +}; + +const readPartText = (part: Part | undefined): string => { + const candidate = part as { text?: unknown; content?: unknown; value?: unknown } | undefined; + if (!candidate) return ''; + const text = typeof candidate.text === 'string' ? candidate.text : ''; + const content = typeof candidate.content === 'string' ? candidate.content : ''; + const value = typeof candidate.value === 'string' ? candidate.value : ''; + return [text, content, value].reduce((best, next) => (next.length > best.length ? next : best), ''); +}; + +export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolean => { + if (left === right) return true; + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + + if (leftPart.type !== rightPart.type) { + return false; + } + + const leftId = readPartId(leftPart); + const rightId = readPartId(rightPart); + if (leftId !== rightId) { + return false; + } + + if (leftPart.type === 'tool') { + if (readToolStatus(leftPart) !== readToolStatus(rightPart)) { + return false; + } + const leftTime = readPartTime(leftPart); + const rightTime = readPartTime(rightPart); + if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) { + return false; + } + const leftTool = (leftPart as { tool?: unknown }).tool; + const rightTool = (rightPart as { tool?: unknown }).tool; + if (leftTool !== rightTool) { + return false; + } + continue; + } + + const leftTime = readPartTime(leftPart); + const rightTime = readPartTime(rightPart); + if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) { + return false; + } + + if (leftPart.type === 'text' || leftPart.type === 'reasoning') { + if (readPartText(leftPart) !== readPartText(rightPart)) { + return false; + } + } + } + + return true; +}; + +export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => { + if (left === right) return true; + + return left.id === right.id + && left.role === right.role + && left.sessionID === right.sessionID + && (left as { finish?: unknown }).finish === (right as { finish?: unknown }).finish + && (left as { status?: unknown }).status === (right as { status?: unknown }).status + && (left as { mode?: unknown }).mode === (right as { mode?: unknown }).mode + && (left as { agent?: unknown }).agent === (right as { agent?: unknown }).agent + && (left as { providerID?: unknown }).providerID === (right as { providerID?: unknown }).providerID + && (left as { modelID?: unknown }).modelID === (right as { modelID?: unknown }).modelID + && (left as { variant?: unknown }).variant === (right as { variant?: unknown }).variant + && (left as { clientRole?: unknown }).clientRole === (right as { clientRole?: unknown }).clientRole + && (left as { userMessageMarker?: unknown }).userMessageMarker === (right as { userMessageMarker?: unknown }).userMessageMarker + && ((left as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) + && ((left as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null); +}; + +export const areRenderRelevantMessagesEqual = (left: MessageRecord, right: MessageRecord): boolean => { + return areRenderRelevantMessageInfoEqual(left.info, right.info) && areRenderRelevantPartsEqual(left.parts, right.parts); +}; + +export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, right?: MessageRecord): boolean => { + if (!left || !right) { + return left === right; + } + return areRenderRelevantMessagesEqual(left, right); +}; diff --git a/packages/ui/src/components/comments/useInlineCommentController.ts b/packages/ui/src/components/comments/useInlineCommentController.ts index e70cbd5f..db27e875 100644 --- a/packages/ui/src/components/comments/useInlineCommentController.ts +++ b/packages/ui/src/components/comments/useInlineCommentController.ts @@ -1,7 +1,7 @@ import React from 'react'; import { toast } from '@/components/ui'; import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type LineRangeBase = { start: number; @@ -48,8 +48,8 @@ export function useInlineCommentController( ) { const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options; - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const addDraft = useInlineCommentDraftStore((state) => state.addDraft); const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft); diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index b8550424..235f2c26 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -7,13 +7,12 @@ import { deriveMessageRole } from '@/components/chat/message/messageRole'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; import { copyTextToClipboard } from '@/lib/clipboard'; type SessionMessage = { info: Message; parts: Part[] }; -const EMPTY_SESSION_MESSAGES: SessionMessage[] = []; - type ProviderModelLike = { id?: string; name?: string; @@ -277,12 +276,9 @@ export const ContextPanelContent: React.FC = () => { const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); const copyResetTimeoutRef = React.useRef(null); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); - const sessionMessages = useSessionStore((state) => { - if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES; - return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES; - }); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const sessionMessages = useSessionMessageRecords(currentSessionId ?? ''); const providers = useConfigStore((state) => state.providers); React.useEffect(() => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4fd67a54..6dce9625 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -20,7 +20,9 @@ import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; +import { getAllSyncSessions } from '@/sync/sync-refs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -241,17 +243,13 @@ export const Header: React.FC = ({ const { getCurrentModel } = useConfigStore(); const runtimeApis = useRuntimeAPIs(); - const getContextUsage = useSessionStore((state) => state.getContextUsage); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const isNewSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const currentSessionMessages = useSessionStore((state) => { - if (!currentSessionId) { - return undefined; - } - return state.messages.get(currentSessionId); - }); - const sessions = useSessionStore((state) => state.sessions); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined; + const sessions = useSessions(); const activeProject = useProjectsStore((state) => { if (!state.activeProjectId) { return null; @@ -565,14 +563,20 @@ export const Header: React.FC = ({ const currentSession = React.useMemo(() => { if (!currentSessionId) return null; - return sessions.find((s) => s.id === currentSessionId) ?? null; + // Try current directory's store first, then fall back to all child stores. + // The sidebar loads sessions globally via SDK, but the header uses + // useSessions() which only has the current directory. This fallback + // ensures the title/directory show when the session lives elsewhere. + return sessions.find((s) => s.id === currentSessionId) + ?? getAllSyncSessions().find((s) => s.id === currentSessionId) + ?? null; }, [currentSessionId, sessions]); - const worktreePath = useSessionStore((state) => { + const worktreePath = useSessionUIStore((state) => { if (!currentSessionId) return ''; return state.worktreeMetadata.get(currentSessionId)?.path ?? ''; }); - const currentSessionWorktreeBranch = useSessionStore((state) => { + const currentSessionWorktreeBranch = useSessionUIStore((state) => { if (!currentSessionId) return null; return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null; }); @@ -588,7 +592,7 @@ export const Header: React.FC = ({ return normalize(raw || ''); }, [currentSession?.directory]); - const draftDirectory = useSessionStore((state) => { + const draftDirectory = useSessionUIStore((state) => { if (!state.newSessionDraft?.open) { return ''; } diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 1b7d7b82..9e195f9a 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -39,7 +39,8 @@ export const RightSidebarTabs: React.FC = () => {
- {rightSidebarTab === 'git' ? : } + {rightSidebarTab === 'git' && } + {rightSidebarTab === 'files' && }
); diff --git a/packages/ui/src/components/layout/SidebarContextSummary.tsx b/packages/ui/src/components/layout/SidebarContextSummary.tsx index 723d2d8d..944b1970 100644 --- a/packages/ui/src/components/layout/SidebarContextSummary.tsx +++ b/packages/ui/src/components/layout/SidebarContextSummary.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { cn } from '@/lib/utils'; @@ -23,8 +24,8 @@ const formatDirectoryPath = (path?: string) => { }; export const SidebarContextSummary: React.FC = ({ className }) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const { currentDirectory } = useDirectoryStore(); const activeSessionTitle = React.useMemo(() => { diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 29548903..254c21d0 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -453,13 +453,29 @@ export const SidebarFilesTree: React.FC = () => { React.useEffect(() => { if (!root || expandedPaths.length === 0) return; - for (const expandedPath of expandedPaths) { - const normalized = normalizePath(expandedPath); - if (!normalized || normalized === root) continue; - if (!normalized.startsWith(`${root}/`)) continue; - if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue; - void loadDirectory(normalized); - } + // Sort by depth so parent dirs load before children + const toLoad = expandedPaths + .map((p) => normalizePath(p)) + .filter((normalized): normalized is string => + !!normalized && + normalized !== root && + normalized.startsWith(`${root}/`) && + !loadedDirsRef.current.has(normalized) && + !inFlightDirsRef.current.has(normalized), + ) + .sort((a, b) => a.split('/').length - b.split('/').length); + + if (toLoad.length === 0) return; + + // Load with concurrency limit to avoid API stampede on startup + let cancelled = false; + void (async () => { + for (let i = 0; i < toLoad.length && !cancelled; i += 3) { + const batch = toLoad.slice(i, i + 3); + await Promise.all(batch.map((dir) => loadDirectory(dir))); + } + })(); + return () => { cancelled = true; }; }, [expandedPaths, loadDirectory, root]); // --- Fuzzy search scoring (matching FilesView) --- diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index ff1adccf..7675f29a 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { ChatView, SettingsView } from '@/components/views'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; @@ -74,7 +76,7 @@ export const VSCodeLayout: React.FC = () => { const bootDraftOpen = React.useMemo(() => { try { - return Boolean(useSessionStore.getState().newSessionDraft?.open); + return Boolean(useSessionUIStore.getState().newSessionDraft?.open); } catch { return false; } @@ -88,8 +90,8 @@ export const VSCodeLayout: React.FC = () => { const expandedSidebarResizeStartXRef = React.useRef(0); const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH); const expandedSidebarResizePointerIdRef = React.useRef(null); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const activeSessionTitle = React.useMemo(() => { if (!currentSessionId) { @@ -97,21 +99,21 @@ export const VSCodeLayout: React.FC = () => { } return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; }, [currentSessionId, sessions]); - const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const isSyncingMessages = useSessionStore((state) => state.isSyncing); - const hasActiveSessionWork = useSessionStore((state) => { - const statuses = state.sessionStatus; - if (!statuses || statuses.size === 0) { + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const isSyncingMessages = useViewportStore((state) => state.isSyncing); + const hasActiveSessionWork = useDirectorySync((state) => { + const statuses = state.session_status; + if (!statuses || Object.keys(statuses).length === 0) { return false; } - for (const status of statuses.values()) { + for (const status of Object.values(statuses)) { if (status?.type === 'busy' || status?.type === 'retry') { return true; } } return false; }); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( () => (typeof window !== 'undefined' ? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as @@ -120,9 +122,6 @@ export const VSCodeLayout: React.FC = () => { ); const configInitialized = useConfigStore((state) => state.isInitialized); const initializeConfig = useConfigStore((state) => state.initializeApp); - const loadSessions = useSessionStore((state) => state.loadSessions); - const loadMessages = useSessionStore((state) => state.loadMessages); - const messages = useSessionStore((state) => state.messages); const [hasInitializedOnce, setHasInitializedOnce] = React.useState(() => configInitialized); const [isInitializing, setIsInitializing] = React.useState(false); const lastBootstrapAttemptAt = React.useRef(0); @@ -158,18 +157,11 @@ export const VSCodeLayout: React.FC = () => { } const timeoutId = window.setTimeout(() => { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const stillNoSession = !state.currentSessionId; const draftStillClosed = !state.newSessionDraft?.open; - const stillSyncing = state.isSyncing; - const stillActiveWork = (() => { - const statuses = state.sessionStatus; - if (!statuses || statuses.size === 0) return false; - for (const status of statuses.values()) { - if (status?.type === 'busy' || status?.type === 'retry') return true; - } - return false; - })(); + const stillSyncing = useViewportStore.getState().isSyncing; + const stillActiveWork = false; // sync bootstrap tracks session status if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) { setCurrentView('sessions'); @@ -270,17 +262,10 @@ export const VSCodeLayout: React.FC = () => { if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) { return; } - await loadSessions(); - const sessionsError = useSessionStore.getState().error; if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', { providers: configState.providers.length, agents: configState.agents.length, - sessions: useSessionStore.getState().sessions.length, - sessionsError, }); - if (typeof sessionsError === 'string' && sessionsError.length > 0) { - return; - } setHasInitializedOnce(true); } catch { // Ignore bootstrap failures @@ -289,7 +274,7 @@ export const VSCodeLayout: React.FC = () => { } }; void runBootstrap(); - }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]); + }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing]); React.useEffect(() => { if (viewMode !== 'editor') { @@ -314,35 +299,9 @@ export const VSCodeLayout: React.FC = () => { } hasAppliedInitialSession.current = true; - void useSessionStore.getState().setCurrentSession(initialSessionId); + void useSessionUIStore.getState().setCurrentSession(initialSessionId); }, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]); - // Hydrate messages when viewing chat - React.useEffect(() => { - const hydrateMessages = async () => { - if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) { - return; - } - - if (!currentSessionId) { - return; - } - - const hasMessagesEntry = messages.has(currentSessionId); - if (hasMessagesEntry) { - return; - } - - try { - await loadMessages(currentSessionId); - } catch { - /* ignored */ - } - }; - - void hydrateMessages(); - }, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]); - // Track container width for responsive settings layout React.useEffect(() => { const container = containerRef.current; @@ -532,8 +491,8 @@ interface VSCodeHeaderProps { } const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => { - const { getCurrentModel } = useConfigStore(); - const getContextUsage = useSessionStore((state) => state.getContextUsage); + const getCurrentModel = useConfigStore((s) => s.getCurrentModel); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 2ec20c19..b96a9e00 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -11,7 +11,7 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/openchamberConfig'; @@ -440,7 +440,7 @@ export const MultiRunLauncher: React.FC = ({ const result = await createMultiRun(params); if (result) { if (result.firstSessionId) { - useSessionStore.getState().setCurrentSession(result.firstSessionId); + useSessionUIStore.getState().setCurrentSession(result.firstSessionId); } // Close launcher diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index b8312525..d3fc6f0f 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -5,8 +5,7 @@ import { NumberInput } from '@/components/ui/number-input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { useDirectorySync } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { opencodeClient } from '@/lib/opencode/client'; @@ -184,7 +183,6 @@ const buildPermissionConfigWithGlobal = ( export const AgentsPage: React.FC = () => { const { isMobile } = useDeviceInfo(); const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); - useConfigStore(); const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent); @@ -220,7 +218,7 @@ export const AgentsPage: React.FC = () => { const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const [toolIds, setToolIds] = React.useState([]); - const permissionsBySession = usePermissionStore((state) => state.permissions); + const permissionsBySession = useDirectorySync((state) => state.permission); React.useEffect(() => { let cancelled = false; @@ -264,7 +262,7 @@ export const AgentsPage: React.FC = () => { } } - for (const permissions of permissionsBySession.values()) { + for (const permissions of Object.values(permissionsBySession)) { for (const request of permissions) { const permissionName = request.permission?.trim(); if (permissionName && permissionName !== 'invalid') { diff --git a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx index 6c9471cf..9c36311a 100644 --- a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx @@ -5,33 +5,44 @@ import { toast } from '@/components/ui'; import { NumberInput } from '@/components/ui/number-input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; const MIN_DAYS = 1; const MAX_DAYS = 365; const DEFAULT_RETENTION_DAYS = 30; +const RETENTION_ACTION_OPTIONS = [ + { value: 'archive', label: 'Archive' }, + { value: 'delete', label: 'Delete' }, +] as const; export const SessionRetentionSettings: React.FC = () => { const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction); const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled); const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays); + const setSessionRetentionAction = useUIStore((state) => state.setSessionRetentionAction); - const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false }); + const { candidates, isRunning, runCleanup, action } = useSessionAutoCleanup({ autoRun: false }); const pendingCount = candidates.length; const handleRunCleanup = React.useCallback(async () => { const result = await runCleanup({ force: true }); - if (result.deletedIds.length === 0 && result.failedIds.length === 0) { - toast.message('No sessions eligible for deletion'); + const verb = result.action === 'archive' ? 'archiving' : 'deletion'; + const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted'; + const failureVerb = result.action === 'archive' ? 'archive' : 'delete'; + + if (result.completedIds.length === 0 && result.failedIds.length === 0) { + toast.message(`No sessions eligible for ${verb}`); return; } - if (result.deletedIds.length > 0) { - toast.success(`Deleted ${result.deletedIds.length} session${result.deletedIds.length === 1 ? '' : 's'}`); + if (result.completedIds.length > 0) { + toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`); } if (result.failedIds.length > 0) { - toast.error(`Failed to delete ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`); + toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`); } }, [runCleanup]); @@ -47,7 +58,7 @@ export const SessionRetentionSettings: React.FC = () => { - Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions. + Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions. @@ -103,6 +114,31 @@ export const SessionRetentionSettings: React.FC = () => { + +
+
+ When sessions expire +
+
+ {RETENTION_ACTION_OPTIONS.map((option) => ( + + ))} +
+
@@ -124,7 +160,7 @@ export const SessionRetentionSettings: React.FC = () => {

- Eligible for deletion right now: {pendingCount} + Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: {pendingCount}

diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 881a47ad..4bd39e68 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { checkIsGitRepository } from '@/lib/gitApi'; @@ -24,7 +25,8 @@ export const WorktreeSectionContent: React.FC = ({ const projectPath = projectRefProp?.path ?? activeProject?.path ?? null; - const { sessions, getWorktreeMetadata } = useSessionStore(); + const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const [setupCommands, setSetupCommands] = React.useState([]); diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx index f88def66..b1a0b79b 100644 --- a/packages/ui/src/components/session/BranchPickerDialog.tsx +++ b/packages/ui/src/components/session/BranchPickerDialog.tsx @@ -27,7 +27,7 @@ import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { sessionEvents } from '@/lib/sessionEvents'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessions } from '@/sync/sync-context'; export interface BranchPickerProject { id: string; @@ -65,7 +65,7 @@ const normalizePath = (value: string | null | undefined): string => { }; export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) { - const sessions = useSessionStore((state) => state.sessions); + const sessions = useSessions(); const [searchQuery, setSearchQuery] = React.useState(''); const [branches, setBranches] = React.useState(null); const [worktrees, setWorktrees] = React.useState([]); diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 883f5a44..a48afb05 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -21,9 +21,10 @@ import { import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; @@ -377,7 +378,7 @@ export function GitHubIssuePickerDialog({ return created.id; } - const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); if (!session?.id) { throw new Error('Failed to create session'); } @@ -385,10 +386,10 @@ export function GitHubIssuePickerDialog({ })(); // Ensure worktree-based sessions also get the issue title. - void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); try { - useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); } catch { // ignore } @@ -397,7 +398,7 @@ export function GitHubIssuePickerDialog({ onOpenChange(false); const configState = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index f36dd3fc..b8f3c5f0 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -10,15 +10,19 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectLabel, - SelectGroup, - SelectSeparator, -} from '@/components/ui/select'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; import { RiGitBranchLine, RiGitRepositoryLine, @@ -29,14 +33,16 @@ import { RiCheckLine, RiExternalLinkLine, RiCloseLine, + RiArrowDownSLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; @@ -44,6 +50,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { opencodeClient } from '@/lib/opencode/client'; +import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; @@ -233,12 +240,6 @@ export function NewWorktreeDialog({ const isLoadingBranches = useGitStore((state) => state.isLoadingBranches); const fetchBranches = useGitStore((state) => state.fetchBranches); - React.useEffect(() => { - if (!open || !projectDirectory || !git) return; - if (branches?.all) return; - void fetchBranches(projectDirectory, git); - }, [open, projectDirectory, git, branches?.all, fetchBranches]); - // Compute local and remote branch lists (same pattern as GitView) const localBranches = React.useMemo(() => { if (!branches?.all) return []; @@ -256,8 +257,7 @@ export function NewWorktreeDialog({ }, [branches]); // Get existing worktrees for the current project to avoid conflicts - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const loadSessions = useSessionStore((state) => state.loadSessions); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const existingWorktreeNames = React.useMemo(() => { if (!projectDirectory) return new Set(); const worktrees = availableWorktreesByProject.get(projectDirectory) ?? []; @@ -278,10 +278,122 @@ export function NewWorktreeDialog({ const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + // Desktop branch picker states + const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); + const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false); + // Mobile branch picker states const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false); const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false); - + + // Shared query state per picker (desktop + mobile) + const [existingBranchQuery, setExistingBranchQuery] = React.useState(''); + const [sourceBranchQuery, setSourceBranchQuery] = React.useState(''); + const existingBranchDropdownContentRef = React.useRef(null); + const sourceBranchDropdownContentRef = React.useRef(null); + const existingBranchMobileListWrapperRef = React.useRef(null); + const sourceBranchMobileListWrapperRef = React.useRef(null); + + const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let node: HTMLElement | null = startNode; + while (node && node !== document.body) { + const { overflowY } = window.getComputedStyle(node); + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return null; + }, []); + + const resetScrollToTop = React.useCallback((container: HTMLElement | null) => { + if (!container) { + return; + } + container.scrollTop = 0; + }, []); + + const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject) => { + const list = contentRef.current?.querySelector('[data-slot="command-list"]') ?? null; + resetScrollToTop(list); + }, [resetScrollToTop]); + + const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject) => { + const scrollContainer = findScrollableContainer(wrapperRef.current); + resetScrollToTop(scrollContainer); + }, [findScrollableContainer, resetScrollToTop]); + + const existingBranchRankedGroups = React.useMemo(() => { + return rankBranchesForQuery({ + localBranches, + remoteBranches, + query: existingBranchQuery, + }); + }, [localBranches, remoteBranches, existingBranchQuery]); + + const sourceBranchRankedGroups = React.useMemo(() => { + return rankBranchesForQuery({ + localBranches, + remoteBranches, + query: sourceBranchQuery, + }); + }, [localBranches, remoteBranches, sourceBranchQuery]); + + const hasExistingBranchQuery = existingBranchQuery.trim().length > 0; + const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0; + const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0; + const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0; + const canFetchBranches = Boolean(projectDirectory && git); + + const handleFetchBranches = React.useCallback(() => { + if (!projectDirectory || !git) { + return; + } + void fetchBranches(projectDirectory, git); + }, [projectDirectory, git, fetchBranches]); + + React.useEffect(() => { + if (!existingBranchDropdownOpen && !existingBranchPickerOpen) { + setExistingBranchQuery(''); + } + }, [existingBranchDropdownOpen, existingBranchPickerOpen]); + + React.useEffect(() => { + if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) { + setSourceBranchQuery(''); + } + }, [sourceBranchDropdownOpen, sourceBranchPickerOpen]); + + React.useEffect(() => { + if (existingBranchDropdownOpen) { + resetDesktopPickerScroll(existingBranchDropdownContentRef); + } + if (existingBranchPickerOpen) { + resetMobilePickerScroll(existingBranchMobileListWrapperRef); + } + }, [ + existingBranchDropdownOpen, + existingBranchPickerOpen, + existingBranchQuery, + resetDesktopPickerScroll, + resetMobilePickerScroll, + ]); + + React.useEffect(() => { + if (sourceBranchDropdownOpen) { + resetDesktopPickerScroll(sourceBranchDropdownContentRef); + } + if (sourceBranchPickerOpen) { + resetMobilePickerScroll(sourceBranchMobileListWrapperRef); + } + }, [ + sourceBranchDropdownOpen, + sourceBranchPickerOpen, + sourceBranchQuery, + resetDesktopPickerScroll, + resetMobilePickerScroll, + ]); + // Validation state const [validation, setValidation] = React.useState({ isValidating: false, @@ -399,7 +511,7 @@ export function NewWorktreeDialog({ } const configState = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; @@ -630,6 +742,12 @@ Nice-to-have: selectedBranch: '', worktreeName: '', }); + setExistingBranchDropdownOpen(false); + setSourceBranchDropdownOpen(false); + setExistingBranchPickerOpen(false); + setSourceBranchPickerOpen(false); + setExistingBranchQuery(''); + setSourceBranchQuery(''); setValidation({ isValidating: false, branchError: null, @@ -847,16 +965,16 @@ Nice-to-have: ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() : 'New session'; - const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null); + const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { throw new Error('Failed to create session'); } createdSessionId = session.id; - void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined); + void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined); try { - useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); + useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); } catch { // ignore } @@ -871,8 +989,6 @@ Nice-to-have: description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`, }); - void loadSessions().catch(() => undefined); - onOpenChange(false); if (createdSessionId) { @@ -1037,17 +1153,29 @@ Nice-to-have: - +
+ + +
{/* Mobile Branch Picker Overlay */} setExistingBranchPickerOpen(false)} > -
+
+ setExistingBranchQuery(e.target.value)} + placeholder="Search branches..." + className="h-8" + /> {isLoadingBranches ? (
Loading branches... @@ -1065,14 +1199,52 @@ Nice-to-have: No branches found
) : ( - <> - {localBranches.length > 0 && ( +
+ {hasExistingBranchQuery && hasExistingBranchMatches && (
- Local branches + Matching branches
- {localBranches.map(branch => ( + {existingBranchRankedGroups.matching.map((branch) => ( + + ))} +
+
+ )} + + {hasExistingBranchQuery && !hasExistingBranchMatches && ( +
+ No matching branches +
+ )} + + {existingBranchRankedGroups.otherLocal.length > 0 && ( +
+
+ {hasExistingBranchQuery ? 'Other local branches' : 'Local branches'} +
+
+ {existingBranchRankedGroups.otherLocal.map((branch) => (
)} - {remoteBranches.length > 0 && ( + + {existingBranchRankedGroups.otherRemote.length > 0 && (
- Remote branches + {hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}
- {remoteBranches.map(branch => ( + {existingBranchRankedGroups.otherRemote.map((branch) => (
)} - +
)}
@@ -1274,7 +1447,13 @@ Nice-to-have: title="Select Source Branch" onClose={() => setSourceBranchPickerOpen(false)} > -
+
+ setSourceBranchQuery(e.target.value)} + placeholder="Search branches..." + className="h-8" + /> {isLoadingBranches ? (
Loading branches... @@ -1284,14 +1463,47 @@ Nice-to-have: No branches found
) : ( - <> - {localBranches.length > 0 && ( +
+ {hasSourceBranchQuery && hasSourceBranchMatches && (
- Local branches + Matching branches
- {localBranches.map(branch => ( + {sourceBranchRankedGroups.matching.map((branch) => ( + + ))} +
+
+ )} + + {hasSourceBranchQuery && !hasSourceBranchMatches && ( +
+ No matching branches +
+ )} + + {sourceBranchRankedGroups.otherLocal.length > 0 && ( +
+
+ {hasSourceBranchQuery ? 'Other local branches' : 'Local branches'} +
+
+ {sourceBranchRankedGroups.otherLocal.map((branch) => (
)} - {remoteBranches.length > 0 && ( + + {sourceBranchRankedGroups.otherRemote.length > 0 && (
- Remote branches + {hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}
- {remoteBranches.map(branch => ( + {sourceBranchRankedGroups.otherRemote.map((branch) => (
)} - +
)}
@@ -1435,59 +1648,129 @@ Nice-to-have: - -
+
+ + + + + + + + + {isLoadingBranches ? ( +
+ Loading branches... +
+ ) : localBranches.length === 0 && remoteBranches.length === 0 ? ( + No branches found + ) : ( + <> + {hasExistingBranchQuery && hasExistingBranchMatches && ( + + {existingBranchRankedGroups.matching.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: branch.value, + worktreeName: slugifyWorktreeName(branch.label), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch.label} + + ))} + + )} + + {hasExistingBranchQuery && !hasExistingBranchMatches && ( +
+ No matching branches +
+ )} + + {existingBranchRankedGroups.otherLocal.length > 0 && ( + <> + {hasExistingBranchQuery && } + + {existingBranchRankedGroups.otherLocal.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: branch, + worktreeName: slugifyWorktreeName(branch), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch} + + ))} + + + )} + + {existingBranchRankedGroups.otherRemote.length > 0 && ( + <> + {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + + )} + + {existingBranchRankedGroups.otherRemote.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: `remotes/${branch}`, + worktreeName: slugifyWorktreeName(branch), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch} + + ))} + + + )} + + )} +
+
+
+
+ +
+
) : (
@@ -1606,51 +1889,101 @@ Nice-to-have: - + + + + {newBranchState.sourceBranch && (
New branch will be created from {newBranchState.sourceBranch} diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 336c223b..9cae301a 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -19,7 +19,8 @@ import { type ProjectRef, } from '@/lib/openchamberConfig'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { cn } from '@/lib/utils'; @@ -52,9 +53,9 @@ export const ProjectNotesTodoPanel: React.FC = ({ const [sendingTodoId, setSendingTodoId] = React.useState(null); const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 165ff0f3..8cd22e5c 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -18,7 +18,8 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import * as sessionActions from '@/sync/session-actions'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -59,17 +60,14 @@ export const SessionDialogs: React.FC = () => { const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState>(new Set()); - const { - deleteSession, - deleteSessions, - archiveSession, - archiveSessions, - loadSessions, - getWorktreeMetadata, - newSessionDraft, - setNewSessionDraftTarget, - setDraftBootstrapPendingDirectory, - } = useSessionStore(); + const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); + const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); + const deleteSession = sessionActions.deleteSession; + const archiveSession = sessionActions.archiveSession; + const deleteSessions = useSessionUIStore((s) => s.deleteSessions); + const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); @@ -113,24 +111,7 @@ export const SessionDialogs: React.FC = () => { isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete; - React.useEffect(() => { - loadSessions(); - }, [loadSessions, currentDirectory]); - - const projectsKey = React.useMemo( - () => projects.map((project) => `${project.id}:${project.path}`).join('|'), - [projects], - ); - const lastProjectsKeyRef = React.useRef(projectsKey); - - React.useEffect(() => { - if (projectsKey === lastProjectsKeyRef.current) { - return; - } - - lastProjectsKeyRef.current = projectsKey; - loadSessions(); - }, [loadSessions, projectsKey]); + // Session loading is handled by sync bootstrap — no manual loadSessions needed. React.useEffect(() => { if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { @@ -444,7 +425,6 @@ export const SessionDialogs: React.FC = () => { description: renderToastDescription(archiveNote), }); closeDeleteDialog(); - loadSessions(); return; } @@ -497,10 +477,8 @@ export const SessionDialogs: React.FC = () => { if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. - const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); - if (removed) { - await loadSessions(); - } + await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + // sync handles session refresh automatically } if (deletedIds.length > 0) { @@ -537,10 +515,8 @@ export const SessionDialogs: React.FC = () => { } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); - if (removed) { - await loadSessions(); - } + await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + // sync bootstrap refreshes sessions automatically } closeDeleteDialog(); @@ -560,7 +536,6 @@ export const SessionDialogs: React.FC = () => { isWorktreeDelete, canRemoveRemoteBranches, removeSelectedWorktree, - loadSessions, ]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f644c071..d38fd615 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -7,8 +7,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { sessionEvents } from '@/lib/sessionEvents'; import { formatDirectoryName, cn } from '@/lib/utils'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSync } from '@/sync/use-sync'; +import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import type { GitHubPullRequestStatus } from '@/lib/api/types'; @@ -26,7 +30,6 @@ import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSel import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering'; import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping'; import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects'; -import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe'; import { useSessionActions } from './sidebar/hooks/useSessionActions'; import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; @@ -44,6 +47,9 @@ import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { useUpdateStore } from '@/stores/useUpdateStore'; +import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { checkIsGitRepository } from '@/lib/gitApi'; +import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; import { FolderDeleteConfirmDialog, @@ -64,6 +70,7 @@ import { formatProjectLabel, normalizePath, } from './sidebar/utils'; +import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -299,27 +306,93 @@ export const SessionSidebar: React.FC = ({ const gitDirectories = useGitStore((state) => state.directories); - const sessions = useSessionStore((state) => state.sessions); - const archivedSessions = useSessionStore((state) => state.archivedSessions); - const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const loadMessages = useSessionStore((state) => state.loadMessages); - const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); - const shareSession = useSessionStore((state) => state.shareSession); - const unshareSession = useSessionStore((state) => state.unshareSession); - const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); - const sessionStatus = useSessionStore((state) => state.sessionStatus); - const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); - const permissions = useSessionStore((state) => state.permissions); - const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const sync = useSync(); + const syncSessions = useSessions(); + const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); + const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory); + const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); + const shareSession = useSessionUIStore((state) => state.shareSession); + const unshareSession = useSessionUIStore((state) => state.unshareSession); + const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState); + const globalSessionStatuses = useAllSessionStatuses(); + // sessionAttentionStates removed — now using notification-store directly in SessionNodeItem + const permissionsRecord = useDirectorySync((state) => state.permission); + + const sessionStatus = React.useMemo( + () => new Map(Object.entries(globalSessionStatuses)), + [globalSessionStatuses], + ); + const permissions = React.useMemo( + () => new Map(Object.entries(permissionsRecord)), + [permissionsRecord], + ); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const prStatusEntries = useGitHubPrStatusStore((state) => state.entries); const updateStore = useUpdateStore(); + const sessions = React.useMemo( + () => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions), + [globalActiveSessions, hasLoadedGlobalSessions, syncSessions], + ); + + const syncSessionSignature = React.useMemo( + () => syncSessions + .map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`) + .join('|'), + [syncSessions], + ); + + React.useEffect(() => { + let cancelled = false; + + const discoverWorktrees = async () => { + const projectEntries = useProjectsStore.getState().projects; + if (projectEntries.length === 0) return; + + const worktreesByProject = new Map(); + const allWorktrees: WorktreeMetadata[] = []; + + await Promise.all( + projectEntries.map(async (project) => { + const projectPath = normalizePath(project.path); + if (!projectPath) return; + try { + const isGitRepo = await checkIsGitRepository(projectPath); + if (!isGitRepo) return; + const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); + if (cancelled || worktrees.length === 0) return; + worktreesByProject.set(projectPath, worktrees); + allWorktrees.push(...worktrees); + } catch { + // ignore discovery errors + } + }), + ); + + if (cancelled) return; + + useSessionUIStore.setState({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: worktreesByProject, + }); + }; + + void refreshGlobalSessions(syncSessions); + void discoverWorktrees(); + + return () => { + cancelled = true; + }; + }, [currentDirectory, syncSessionSignature, syncSessions]); + const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); @@ -614,10 +687,10 @@ export const SessionSidebar: React.FC = ({ updateStore.available && (updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web'); - const deleteSession = useSessionStore((state) => state.deleteSession); - const deleteSessions = useSessionStore((state) => state.deleteSessions); - const archiveSession = useSessionStore((state) => state.archiveSession); - const archiveSessions = useSessionStore((state) => state.archiveSessions); + const deleteSession = useSessionUIStore((state) => state.deleteSession); + const deleteSessions = useSessionUIStore((state) => state.deleteSessions); + const archiveSession = useSessionUIStore((state) => state.archiveSession); + const archiveSessions = useSessionUIStore((state) => state.archiveSessions); const { copiedSessionId, @@ -820,7 +893,7 @@ export const SessionSidebar: React.FC = ({ setProjectRootBranches, }); - const isSessionsLoading = useSessionStore((state) => state.isLoading); + const isSessionsLoading = useSessionUIStore((state) => state.isLoading); useSessionFolderCleanup({ isSessionsLoading, sessions, @@ -970,6 +1043,7 @@ export const SessionSidebar: React.FC = ({ branchLabel?: string | null; } | null; }>(); + const projectPathLengthBySessionId = new Map(); projectSections.forEach((section) => { const projectLabel = formatProjectLabel( @@ -984,12 +1058,19 @@ export const SessionSidebar: React.FC = ({ const visit = (nodes: SessionNode[]) => { nodes.forEach((node) => { + const nextProjectPathLength = section.project.normalizedPath.length; + const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1; + if (nextProjectPathLength < currentProjectPathLength) { + return; + } + meta.set(node.session.id, { node, projectId: section.project.id, groupDirectory: group.directory, secondaryMeta, }); + projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength); if (node.children.length > 0) { visit(node.children); } @@ -1008,12 +1089,7 @@ export const SessionSidebar: React.FC = ({ [activeNowEntries, sessions], ); - useSessionPrefetch({ - currentSessionId, - sortedSessions, - recentSessionIds: activeNowSessions.map((session) => session.id), - loadMessages, - }); + // Prefetch is wired below, after recentSessionIds is computed. const activitySections = React.useMemo(() => { const toItem = (session: Session) => { @@ -1036,6 +1112,15 @@ export const SessionSidebar: React.FC = ({ return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id))); }, [activitySections]); + const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]); + + useSessionPrefetch({ + currentSessionId, + sortedSessions, + recentSessionIds: recentSessionIdsList, + loadMessages: sync.syncSession, + }); + const sectionsForSidebarRender = React.useMemo(() => { if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) { return sectionsForRender; @@ -1105,7 +1190,6 @@ export const SessionSidebar: React.FC = ({ expandedParents={expandedParents} hasSessionSearchQuery={hasSessionSearchQuery} normalizedSessionSearchQuery={normalizedSessionSearchQuery} - sessionAttentionStates={sessionAttentionStates as Map} notifyOnSubtasks={notifyOnSubtasks} sessionStatus={sessionStatus as Map | undefined} permissions={permissions as Map} @@ -1147,7 +1231,6 @@ export const SessionSidebar: React.FC = ({ expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, - sessionAttentionStates, notifyOnSubtasks, sessionStatus, permissions, diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 23523e3b..1ec78232 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -38,6 +38,7 @@ import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { useSessionUnseenCount } from '@/sync/notification-store'; const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); @@ -65,7 +66,6 @@ type Props = { expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; - sessionAttentionStates: Map; notifyOnSubtasks: boolean; sessionStatus?: Map; permissions: Map; @@ -113,7 +113,6 @@ export function SessionNodeItem(props: Props): React.ReactNode { expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, - sessionAttentionStates, notifyOnSubtasks, sessionStatus, permissions, @@ -177,8 +176,8 @@ export function SessionNodeItem(props: Props): React.ReactNode { const isPinnedSession = pinnedSessionIds.has(session.id); const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); - const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; - const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); + const unseenCount = useSessionUnseenCount(session.id); + const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks); const sessionSummary = session.summary as SessionSummaryMeta | undefined; const sessionDiffStats = resolveSessionDiffStats(sessionSummary); const sessionTimestamp = session.time?.updated || session.time?.created || Date.now(); diff --git a/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts index 622091a2..9e6ebf27 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts @@ -1,25 +1,73 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { normalizePath } from '../utils'; type ProjectLike = { path: string }; +type DirectoryStatusValue = 'unknown' | 'exists' | 'missing'; + type Args = { sortedSessions: Session[]; projects: ProjectLike[]; - directoryStatus: Map; - setDirectoryStatus: React.Dispatch>>; + directoryStatus: Map; + setDirectoryStatus: React.Dispatch>>; }; +const PROBE_CONCURRENCY = 3; +const MISSING_CACHE_KEY = 'oc.directoryProbe.missing'; +// Re-probe missing directories periodically in case they're recreated +const MISSING_REPROBE_MS = 10 * 60 * 1000; // 10 minutes + +type MissingCache = Record; // directory -> timestamp + +function loadMissingCache(): MissingCache { + try { + const raw = localStorage.getItem(MISSING_CACHE_KEY); + if (!raw) return {}; + return JSON.parse(raw) as MissingCache; + } catch { + return {}; + } +} + +function saveMissingCache(cache: MissingCache): void { + try { + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore quota errors + } +} + +async function probeDirectory(directory: string): Promise { + try { + await opencodeClient.listLocalDirectory(directory); + return 'exists'; + } catch { + const looksLikeSdkWorktree = + directory.includes('/opencode/worktree/') || + directory.includes('/.opencode/data/worktree/') || + directory.includes('/.local/share/opencode/worktree/'); + + if (looksLikeSdkWorktree) { + const ok = await opencodeClient.probeDirectory(directory).catch(() => false); + if (ok) return 'exists'; + } + + return 'missing'; + } +} + export const useDirectoryStatusProbe = ({ sortedSessions, projects, directoryStatus, setDirectoryStatus, }: Args): void => { - const directoryStatusRef = React.useRef>(new Map()); - const checkingDirectories = React.useRef>(new Set()); + const directoryStatusRef = React.useRef>(new Map()); + const probeInFlightRef = React.useRef(false); + const missingCacheRef = React.useRef(loadMissingCache()); React.useEffect(() => { directoryStatusRef.current = directoryStatus; @@ -29,68 +77,83 @@ export const useDirectoryStatusProbe = ({ const directories = new Set(); sortedSessions.forEach((session) => { const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (dir) { - directories.add(dir); - } + if (dir) directories.add(dir); }); projects.forEach((project) => { const normalized = normalizePath(project.path); - if (normalized) { - directories.add(normalized); - } + if (normalized) directories.add(normalized); }); - directories.forEach((directory) => { + const now = Date.now(); + const missingCache = missingCacheRef.current; + const toProbe: string[] = []; + const preseeded = new Map(); + + for (const directory of directories) { const known = directoryStatusRef.current.get(directory); - if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) { - return; + if (known && known !== 'unknown') continue; + + // Use cached "missing" status if fresh enough — skip the HTTP probe + const cachedAt = missingCache[directory]; + if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) { + preseeded.set(directory, 'missing'); + continue; } - checkingDirectories.current.add(directory); - opencodeClient - .listLocalDirectory(directory) - .then(() => { - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'exists') { - return prev; - } - next.set(directory, 'exists'); - return next; - }); - }) - .catch(async () => { - const looksLikeSdkWorktree = - directory.includes('/opencode/worktree/') || - directory.includes('/.opencode/data/worktree/') || - directory.includes('/.local/share/opencode/worktree/'); - if (looksLikeSdkWorktree) { - const ok = await opencodeClient.probeDirectory(directory).catch(() => false); - if (ok) { - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'exists') { - return prev; - } - next.set(directory, 'exists'); - return next; - }); - return; - } + toProbe.push(directory); + } + + // Apply preseeded missing statuses immediately (no HTTP call) + if (preseeded.size > 0) { + setDirectoryStatus((prev) => { + let changed = false; + const next = new Map(prev); + for (const [dir, status] of preseeded) { + if (next.get(dir) !== status) { + next.set(dir, status); + changed = true; } + } + return changed ? next : prev; + }); + } - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'missing') { - return prev; - } - next.set(directory, 'missing'); - return next; - }); - }) - .finally(() => { - checkingDirectories.current.delete(directory); + if (toProbe.length === 0 || probeInFlightRef.current) return; + probeInFlightRef.current = true; + + let cancelled = false; + let cacheChanged = false; + + void mapWithConcurrency(toProbe, PROBE_CONCURRENCY, async (directory) => { + const status = await probeDirectory(directory); + + // Update missing cache + if (status === 'missing') { + missingCache[directory] = Date.now(); + cacheChanged = true; + } else if (missingCache[directory]) { + delete missingCache[directory]; + cacheChanged = true; + } + + if (!cancelled) { + setDirectoryStatus((prev) => { + if (prev.get(directory) === status) return prev; + const next = new Map(prev); + next.set(directory, status); + return next; }); + } + return { directory, status }; + }).finally(() => { + probeInFlightRef.current = false; + if (cacheChanged) { + saveMissingCache(missingCache); + } }); + + return () => { + cancelled = true; + }; }, [sortedSessions, projects, setDirectoryStatus]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts index 3119e816..c70836cc 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -1,5 +1,6 @@ import React from 'react'; import { checkIsGitRepository } from '@/lib/gitApi'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; type Project = { id: string; path: string; normalizedPath: string }; @@ -39,26 +40,25 @@ export const useProjectRepoStatus = (args: Args): void => { }; } - normalized.forEach((project) => { - checkIsGitRepository(project.path) - .then((result) => { - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, result); - return next; - }); - } - }) - .catch(() => { - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, null); - return next; - }); - } - }); + void mapWithConcurrency(normalized, 2, async (project) => { + try { + const result = await checkIsGitRepository(project.path); + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, result); + return next; + }); + } + } catch { + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, null); + return next; + }); + } + } }); return () => { @@ -78,12 +78,10 @@ export const useProjectRepoStatus = (args: Args): void => { React.useEffect(() => { let cancelled = false; const run = async () => { - const entries = await Promise.all( - normalizedProjects.map(async (project) => { - const branch = await getRootBranch(project.normalizedPath).catch(() => null); - return { id: project.id, branch }; - }), - ); + const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => { + const branch = await getRootBranch(project.normalizedPath).catch(() => null); + return { id: project.id, branch }; + }); if (cancelled) { return; } diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts index ff304858..211ebd07 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -1,8 +1,10 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncMessages } from '@/sync/sync-refs'; const SESSION_PREFETCH_HOVER_DELAY_MS = 180; +const SESSION_PREFETCH_SETTLE_MS = 600; const SESSION_PREFETCH_CONCURRENCY = 1; const SESSION_PREFETCH_PENDING_LIMIT = 6; @@ -10,7 +12,7 @@ type Args = { currentSessionId: string | null; sortedSessions: Session[]; recentSessionIds?: string[]; - loadMessages: (sessionId: string, limit?: number) => Promise; + loadMessages: (sessionId: string) => Promise; }; export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => { @@ -29,15 +31,14 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes break; } - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); if (state.currentSessionId === nextSessionId) { continue; } - const hasMessages = state.messages.has(nextSessionId); - const historyMeta = state.sessionHistoryMeta.get(nextSessionId); - const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; - if (isHydrated) { + // Check if messages already loaded in sync child store + const hasMessages = getSyncMessages(nextSessionId).length > 0; + if (hasMessages) { continue; } @@ -56,11 +57,9 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes return; } - const state = useSessionStore.getState(); - const hasMessages = state.messages.has(sessionId); - const historyMeta = state.sessionHistoryMeta.get(sessionId); - const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; - if (isHydrated) { + // Already loaded in sync + const hasMessages = getSyncMessages(sessionId).length > 0; + if (hasMessages) { return; } @@ -89,30 +88,32 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes sessionPrefetchTimersRef.current.set(sessionId, timer); }, [currentSessionId, pumpSessionPrefetchQueue]); + // Wait for the active session to finish loading before prefetching neighbors. + // On rapid session switches the timer resets, so only the final session triggers prefetch. React.useEffect(() => { if (!currentSessionId || sortedSessions.length === 0) { return; } - const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); - if (currentIndex < 0) { - return; - } - scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); - scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + const timer = window.setTimeout(() => { + const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); + if (currentIndex < 0) return; + scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); + scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + }, SESSION_PREFETCH_SETTLE_MS); + return () => window.clearTimeout(timer); }, [currentSessionId, scheduleSessionPrefetch, sortedSessions]); React.useEffect(() => { if (!currentSessionId || recentSessionIds.length === 0) { return; } - - const currentIndex = recentSessionIds.indexOf(currentSessionId); - if (currentIndex < 0) { - return; - } - - scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); - scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); + const timer = window.setTimeout(() => { + const currentIndex = recentSessionIds.indexOf(currentSessionId); + if (currentIndex < 0) return; + scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); + scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); + }, SESSION_PREFETCH_SETTLE_MS); + return () => window.clearTimeout(timer); }, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]); React.useEffect(() => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 3075286a..2ae01af4 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -10,43 +10,38 @@ import { CommandShortcut, } from '@/components/ui/command'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDeviceInfo } from '@/lib/device'; -import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; +import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata'; export const CommandPalette: React.FC = () => { - const { - isCommandPaletteOpen, - setCommandPaletteOpen, - setHelpDialogOpen, - setActiveMainTab, - setSettingsDialogOpen, - setSettingsPage, - setSessionSwitcherOpen, - setTimelineDialogOpen, - toggleSidebar, - toggleRightSidebar, - setRightSidebarOpen, - setRightSidebarTab, - toggleBottomTerminal, - setBottomTerminalExpanded, - isBottomTerminalExpanded, - shortcutOverrides, - } = useUIStore(); + const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen); + const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen); + const setHelpDialogOpen = useUIStore((s) => s.setHelpDialogOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setSettingsPage = useUIStore((s) => s.setSettingsPage); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); + const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen); + const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab); + const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal); + const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded); + const isBottomTerminalExpanded = useUIStore((s) => s.isBottomTerminalExpanded); + const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); - const { - openNewSessionDraft, - setCurrentSession, - getSessionsByDirectory, - } = useSessionStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const getSessionsByDirectory = useSessionUIStore((s) => s.getSessionsByDirectory); - const { currentDirectory } = useDirectoryStore(); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const { themeMode, setThemeMode } = useThemeSystem(); const handleClose = () => { @@ -167,11 +162,6 @@ export const CommandPalette: React.FC = () => { handleClose(); }; - const handleOpenTimeline = () => { - setTimelineDialogOpen(true); - handleClose(); - }; - const directorySessions = getSessionsByDirectory(currentDirectory ?? ''); const currentSessions = React.useMemo(() => { return directorySessions.slice(0, 5); @@ -252,11 +242,6 @@ export const CommandPalette: React.FC = () => { Open Git Panel {shortcut('open_git_panel')} - - - Open Timeline - {shortcut('open_timeline')} - Open Settings diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index e09209f7..597cdf7f 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -186,12 +186,6 @@ export const HelpDialog: React.FC = () => { description: "Switch Project", icon: RiLayoutLeftLine, }, - { - id: 'open_timeline', - description: "Open Timeline", - icon: RiTimeLine, - keys: '', - }, { id: 'toggle_services_menu', description: 'Toggle Services Menu', diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index ccb09126..23e97935 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -1,37 +1,162 @@ import React from 'react'; -import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore'; +import { RiBarChartBoxLine, RiCloseLine, RiDatabase2Line, RiFileCopyLine, RiPulseLine, RiRefreshLine } from '@remixicon/react'; + +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync } from '@/sync/sync-context'; +import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; -import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; +import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; +import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; -import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -interface MemoryDebugPanelProps { +interface DebugPanelProps { onClose?: () => void; } -export const MemoryDebugPanel: React.FC = ({ onClose }) => { - const { - sessions, - messages, - sessionMemoryState, - currentSessionId, - } = useSessionStore(); +type DebugTab = 'memory' | 'streaming'; + +const formatDuration = (durationMs: number): string => { + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + + const seconds = durationMs / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainderSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainderSeconds}s`; +}; + +const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => { + return ( +
+
{label}
+
{value}
+
+ ); +}; + +const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; emptyLabel: string }> = ({ title, snapshot, emptyLabel }) => { + const topEntries = snapshot.entries.slice(0, 12); + const totalSamples = snapshot.entries.reduce((sum, entry) => sum + entry.count, 0); + + return ( +
+
+
{title}
+
+ {snapshot.startedAt ? formatDuration(snapshot.durationMs) : 'idle'} +
+
+ +
+ + + +
+ + {topEntries.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( + +
+ {topEntries.map((entry) => ( +
+
{entry.metric}
+
+ count {entry.count} + avg {entry.avg} + max {entry.max} + total {entry.total} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export const DebugPanel: React.FC = ({ onClose }) => { + const [activeTab, setActiveTab] = React.useState('memory'); + const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle'); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState); + const sessions = useSessions(); + const messageRecord = useDirectorySync((state) => state.message); const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); + const [streamSnapshot, setStreamSnapshot] = React.useState(() => getStreamPerfSnapshot()); + const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState(() => getVsCodeStreamPerfSnapshot()); + const streamMetricCounts = React.useMemo(() => { + const counts = new Map(); + streamSnapshot.entries.forEach((entry) => { + counts.set(entry.metric, entry.count); + }); + return { + messageListRender: counts.get('ui.message_list.render') ?? 0, + messageListRenderStreaming: counts.get('ui.message_list.render.streaming') ?? 0, + chatMessageRender: counts.get('ui.chat_message.render') ?? 0, + chatMessageRenderStreaming: counts.get('ui.chat_message.render.streaming') ?? 0, + chatMessageRenderStaticDuringStream: counts.get('ui.chat_message.render.static_during_stream') ?? 0, + chatMessageRenderStaticOutsideActiveTurnDuringStream: + counts.get('ui.chat_message.render.static_outside_active_turn_during_stream') ?? 0, + }; + }, [streamSnapshot.entries]); + + React.useEffect(() => { + const refresh = () => { + setStreamSnapshot(getStreamPerfSnapshot()); + setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot()); + }; + + refresh(); + const intervalId = window.setInterval(refresh, 500); + return () => window.clearInterval(intervalId); + }, []); + + React.useEffect(() => { + if (copyState === 'idle') { + return; + } + + const timeoutId = window.setTimeout(() => { + setCopyState('idle'); + }, 1500); + + return () => window.clearTimeout(timeoutId); + }, [copyState]); const totalMessages = React.useMemo(() => { let total = 0; - messages.forEach((sessionMessages) => { - total += sessionMessages.length; - }); + for (const sessionId of Object.keys(messageRecord)) { + total += messageRecord[sessionId]?.length ?? 0; + } return total; - }, [messages]); + }, [messageRecord]); const sessionStats = React.useMemo(() => { return sessions.map(session => { - const messageCount = messages.get(session.id)?.length || 0; + const messageCount = messageRecord[session.id]?.length || 0; const memoryState = sessionMemoryState.get(session.id); return { id: session.id, @@ -44,120 +169,209 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = isCurrent: session.id === currentSessionId }; }).sort((a, b) => b.lastAccessed - a.lastAccessed); - }, [sessions, messages, sessionMemoryState, currentSessionId]); + }, [sessions, messageRecord, sessionMemoryState, currentSessionId]); - const cachedSessionCount = messages.size; + const cachedSessionCount = Object.keys(messageRecord).length; + + const handleCopyStreamingDebug = React.useCallback(async () => { + try { + const payload = { + generatedAt: new Date().toISOString(), + ui: getStreamPerfSnapshot(), + vscode: getVsCodeStreamPerfSnapshot(), + }; + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + setCopyState('copied'); + } catch { + setCopyState('error'); + } + }, []); return ( - -
+ +
- -

Memory Debug Panel

+ {activeTab === 'memory' ? ( + + ) : ( + + )} +

Debug Panel

- {onClose && ( - - )} -
- -
- {} -
-
-
Total Messages
-
{totalMessages}
-
-
-
Cached Sessions
-
{cachedSessionCount} / {MEMORY_LIMITS.MAX_SESSIONS}
-
-
- - {null} - - {} -
-
- Viewport Window: - {getBackgroundTrimLimit()} messages -
-
- Zombie Timeout: - {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes -
-
- GitHub Total Requests: - {totalGitHubRequests} -
-
- - {} -
-
Sessions in Memory:
- - {sessionStats.map(stat => ( -
-
- {stat.title} - {stat.isStreaming && ( - - )} - {stat.isZombie && ( - ! - )} -
-
- getMessageLimit() ? 'text-status-warning' : '' - }`}> - {stat.messageCount} msgs - - {stat.backgroundCount > 0 && ( - +{stat.backgroundCount} - )} -
-
- ))} -
-
- -
- - +
+ {activeTab === 'streaming' ? ( + <> + - - - Log current memory state to browser console - - + + ) : null} + {onClose ? ( + + ) : null}
+ +
+ + +
+ + {activeTab === 'memory' ? ( +
+
+ + +
+ +
+
+ Viewport Window + {getBackgroundTrimLimit()} messages +
+
+ Zombie Timeout + {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes +
+
+ GitHub Total Requests + {totalGitHubRequests} +
+
+ +
+
Sessions in Memory
+ + {sessionStats.map(stat => ( +
+
+ {stat.title} + {stat.isStreaming ? : null} + {stat.isZombie ? ! : null} +
+
+ + {stat.messageCount} msgs + + {stat.backgroundCount > 0 ? ( + +{stat.backgroundCount} + ) : null} +
+
+ ))} +
+
+ +
+ + + + + Log current memory state to browser console + +
+
+ ) : ( +
+
+ + {copyState === 'copied' + ? 'Streaming debug JSON copied' + : copyState === 'error' + ? 'Failed to copy JSON' + : 'Copy exports both UI and VS Code streaming metrics as JSON'} + + +
+ +
+ + + + + + + + +
+ + + + {vscodeStreamSnapshot.entries.length > 0 ? ( + + ) : null} +
+ )} ); }; + +export const MemoryDebugPanel = DebugPanel; diff --git a/packages/ui/src/components/ui/OpenChamberLogo.tsx b/packages/ui/src/components/ui/OpenChamberLogo.tsx index 60e9f755..5c8d3063 100644 --- a/packages/ui/src/components/ui/OpenChamberLogo.tsx +++ b/packages/ui/src/components/ui/OpenChamberLogo.tsx @@ -192,6 +192,23 @@ export const OpenChamberLogo: React.FC = ({ role="img" aria-label="OpenChamber logo" > + + {/* Left face - base fill */} = ({ /> {/* OpenCode logo on top face */} - - {isAnimated && ( - - )} + {/* Isometric transform for top face: OpenCode logo (32x40 viewBox) centered and projected to isometric plane diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index 88538653..bf2e7eb7 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; export const ChatView: React.FC = () => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 162f44c0..f1a4ad8b 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -31,6 +31,7 @@ import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib // Minimum width for side-by-side diff view (px) const SIDE_BY_SIDE_MIN_WIDTH = 1100; const DIFF_REQUEST_TIMEOUT_MS = 15000; +const LARGE_DIFF_CHANGED_LINES = 500; // Perf: limit concurrent expanded diffs in stacked view. // Expanding many diffs mounts many Pierre instances + lots of DOM. @@ -638,6 +639,7 @@ const MultiFileDiffEntry = React.memo(({ const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); const [diffLoadError, setDiffLoadError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); + const [forceRenderLarge, setForceRenderLarge] = React.useState(false); const lastDiffRequestRef = React.useRef(null); const sectionRef = React.useRef(null); @@ -881,7 +883,24 @@ const MultiFileDiffEntry = React.memo(({ Loading diff…
) : null} - {diffData ? ( + {diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? ( +
+
+ Large diff ({file.insertions + file.deletions} changed lines) +
+
+ Rendering may be slow. You can still view the diff by clicking below. +
+ +
+ ) : null} + {diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? ( = ({ const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); const status = useGitStatus(effectiveDirectory ?? null); const isLoadingStatus = useGitStore((state) => state.isLoadingStatus); - const { setActiveDirectory, fetchStatus, setDiff } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const setDiff = useGitStore((state) => state.setDiff); const [selectedFile, setSelectedFile] = React.useState(null); const [stackedExpandTarget, setStackedExpandTarget] = React.useState(null); @@ -1722,7 +1743,8 @@ export const useDiffFileCount = (): number => { const { git } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); - const { setActiveDirectory, fetchStatus } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchStatus = useGitStore((state) => state.fetchStatus); const fileCount = useGitFileCount(effectiveDirectory ?? null); React.useEffect(() => { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index f202c115..ba5b2ed3 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; @@ -225,13 +225,11 @@ export const GitView: React.FC = () => { const currentDirectory = useEffectiveDirectory(); const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); - const { - currentSessionId, - worktreeMetadata: worktreeMap, - availableWorktrees, - newSessionDraft, - setDraftBootstrapPendingDirectory, - } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); + const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); + const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees); const normalizedCurrentDirectory = normalizePath(currentDirectory); const inferredWorktreeMetadata = React.useMemo(() => { if (!normalizedCurrentDirectory) { @@ -276,16 +274,14 @@ export const GitView: React.FC = () => { const currentIdentity = useGitIdentity(currentDirectory ?? null); const isLoading = useGitStore((state) => state.isLoadingStatus); const isLogLoading = useGitStore((state) => state.isLoadingLog); - const { - setActiveDirectory, - fetchAll, - fetchStatus, - fetchBranches, - fetchLog, - fetchIdentity, - prefetchDiffs, - setLogMaxCount, - } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchAll = useGitStore((state) => state.fetchAll); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const fetchBranches = useGitStore((state) => state.fetchBranches); + const fetchLog = useGitStore((state) => state.fetchLog); + const fetchIdentity = useGitStore((state) => state.fetchIdentity); + const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); + const setLogMaxCount = useGitStore((state) => state.setLogMaxCount); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); const navigateToDiff = useUIStore((state) => state.navigateToDiff); diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index d456ebdf..6191711c 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -29,6 +29,9 @@ import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; +// Threshold (bytes) above which syntax highlighting is degraded for performance +const LARGE_CONTENT_BYTES = 500_000; + interface PierreDiffViewerProps { original: string; modified: string; @@ -439,6 +442,11 @@ export const PierreDiffViewer: React.FC = ({ }, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]); + const isLargeContent = useMemo(() => + Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES, + [original.length, modified.length], + ); + const options = useMemo(() => ({ theme: { dark: darkTheme.metadata.id, @@ -450,8 +458,9 @@ export const PierreDiffViewer: React.FC = ({ hunkSeparators: 'line-info-basic' as const, // Perf: disable intra-line diff (word-level) globally. lineDiffType: 'none' as const, - maxLineDiffLength: 1000, - maxLineLengthForHighlighting: 1000, + // Perf: degrade tokenization/highlighting for large files (>500KB) + maxLineDiffLength: isLargeContent ? 0 : 1000, + maxLineLengthForHighlighting: isLargeContent ? 1 : 1000, expansionLineCount: 20, overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), disableFileHeader: true, @@ -460,7 +469,7 @@ export const PierreDiffViewer: React.FC = ({ onLineSelected: handleSelectionChange, unsafeCSS: WEBKIT_SCROLL_FIX_CSS, renderAnnotation, - }), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]); + }), [darkTheme.metadata.id, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]); const lineAnnotations = useMemo(() => { diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 29799f34..4ab7e4af 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -15,7 +15,8 @@ import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { languageByExtension } from '@/lib/codemirror/languageByExtension'; import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; @@ -80,8 +81,8 @@ type SelectedLineRange = { }; export const PlanView: React.FC = () => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const runtimeApis = useRuntimeAPIs(); useUIStore(); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 90bcb5dc..b64727f5 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; @@ -93,7 +93,8 @@ export const TerminalView: React.FC = () => { const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop); const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop; - const { currentSessionId, newSessionDraft } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; const effectiveDirectory = useEffectiveDirectory() ?? null; diff --git a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx index 1dcd2853..db5012f8 100644 --- a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx @@ -5,6 +5,7 @@ import { RiCheckLine, RiMore2Line, RiFileCopyLine, + RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; @@ -12,7 +13,8 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGlobalSessionStatus, useAllSessionStatuses } from '@/sync/sync-context'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { @@ -35,53 +37,57 @@ interface AgentGroupDetailProps { className?: string; } +const SessionStatusDot: React.FC<{ sessionId: string }> = ({ sessionId }) => { + const status = useGlobalSessionStatus(sessionId); + if (!status || status.type === 'idle') return null; + return ( + + + + + ); +}; + export const AgentGroupDetail: React.FC = ({ group, className, }) => { - const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore(); - const { setCurrentSession, currentSessionId } = useSessionStore(); + const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId); + const selectSession = useAgentGroupsStore((s) => s.selectSession); + const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const [worktreeDialog, setWorktreeDialog] = React.useState(null); const [isProcessing, setIsProcessing] = React.useState(false); - - // Find the currently selected session + const selectedSession = React.useMemo(() => { if (!selectedSessionId) return group.sessions[0] ?? null; return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null; }, [group.sessions, selectedSessionId]); - - // When selecting a session, switch to that OpenCode session - // NOTE: We intentionally do NOT change the global directory here to avoid - // re-triggering loadGroups() which would cause groups to disappear + const handleSessionSelect = React.useCallback((session: AgentGroupSession) => { selectSession(session.id); - - // Switch to the OpenCode session - setCurrentSession(session.id); + setCurrentSession(session.id, session.path); }, [selectSession, setCurrentSession]); - + // Auto-select first session when group changes and sync OpenCode session React.useEffect(() => { if (group.sessions.length > 0) { - const session = selectedSessionId + const session = selectedSessionId ? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] : group.sessions[0]; - - if (session) { - // Always ensure the OpenCode session is synced - if (session.id !== currentSessionId) { - setCurrentSession(session.id); - } - - // Update selection if not already selected - if (!selectedSessionId) { - selectSession(session.id); + + if (session) { + if (session.id !== currentSessionId) { + setCurrentSession(session.id, session.path); + } + if (!selectedSessionId) { + selectSession(session.id); } } } }, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]); - // Check if the current OpenCode session matches the selected agent group session const isSessionSynced = selectedSession?.id === currentSessionId; const handleCopyWorktreePath = React.useCallback(() => { @@ -98,12 +104,12 @@ export const AgentGroupDetail: React.FC = ({ }); }, [selectedSession?.path]); - const handleRemoveSelectedWorktree = React.useCallback(async () => { + const handleRemoveSelectedWorktree = React.useCallback(() => { if (!selectedSession) return; setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel }); }, [selectedSession]); - const handleKeepOnlySelectedWorktree = React.useCallback(async () => { + const handleKeepOnlySelectedWorktree = React.useCallback(() => { if (!selectedSession) return; setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel }); }, [selectedSession]); @@ -112,32 +118,36 @@ export const AgentGroupDetail: React.FC = ({ if (!worktreeDialog || isProcessing) return; setIsProcessing(true); try { + const normalize = (v: string) => v.replace(/\\/g, '/').replace(/\/+$/, '') || v; + const targetPath = normalize(worktreeDialog.path); + let sessionsToDelete: AgentGroupSession[]; + if (worktreeDialog.kind === 'remove') { toast.info('Removing worktree...'); - const ok = await deleteGroupWorktree(group.name, worktreeDialog.path); - if (ok) { - toast.success('Worktree removed'); - } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || 'Failed to remove worktree'); - return; - } + sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath); } else { toast.info('Removing other worktrees...'); - const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path); - if (ok) { - toast.success('Removed other worktrees'); - } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || 'Failed to remove other worktrees'); - return; - } + sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath); + } + + const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true }); + if (failedIds.length > 0 || failedWorktreePaths.length > 0) { + toast.error('Failed to fully remove worktree'); + } else { + toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees'); } setWorktreeDialog(null); } finally { setIsProcessing(false); } - }, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]); + }, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]); + + // Group-level status: show if any session is busy + const allStatuses = useAllSessionStatuses(); + const groupBusy = React.useMemo( + () => group.sessions.some((s) => allStatuses[s.id]?.type === 'busy'), + [group.sessions, allStatuses], + ); return (
@@ -145,7 +155,10 @@ export const AgentGroupDetail: React.FC = ({
-

{group.name}

+
+

{group.name}

+ {groupBusy && } +
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''} · @@ -156,7 +169,7 @@ export const AgentGroupDetail: React.FC = ({
- + {/* Model Selector Dropdown */} {group.sessions.length > 0 && (
@@ -170,9 +183,9 @@ export const AgentGroupDetail: React.FC = ({
{selectedSession && ( <> - {selectedSession.modelId} @@ -182,6 +195,7 @@ export const AgentGroupDetail: React.FC = ({ #{selectedSession.instanceNumber} )} + )}
@@ -195,9 +209,9 @@ export const AgentGroupDetail: React.FC = ({ onClick={() => handleSessionSelect(session)} className="flex items-center gap-2 py-2" > -
@@ -209,6 +223,7 @@ export const AgentGroupDetail: React.FC = ({ #{session.instanceNumber} )} +
{session.branch && (
@@ -236,7 +251,7 @@ export const AgentGroupDetail: React.FC = ({ { e.preventDefault(); - void handleRemoveSelectedWorktree(); + handleRemoveSelectedWorktree(); }} variant="destructive" > @@ -245,7 +260,7 @@ export const AgentGroupDetail: React.FC = ({ { e.preventDefault(); - void handleKeepOnlySelectedWorktree(); + handleKeepOnlySelectedWorktree(); }} > Leave this one, remove others @@ -292,7 +307,7 @@ export const AgentGroupDetail: React.FC = ({ - + {/* Chat Content */}
{selectedSession ? ( @@ -302,7 +317,6 @@ export const AgentGroupDetail: React.FC = ({ ) : (
- {/* Info banner about the worktree */}
@@ -315,8 +329,6 @@ export const AgentGroupDetail: React.FC = ({
- - {/* Loading or no session state */}

diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx index 5ed1f5ee..25ff2a3d 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx @@ -5,6 +5,7 @@ import { RiMore2Line, RiSearchLine, RiGitBranchLine, + RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Input } from '@/components/ui/input'; @@ -26,16 +27,16 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useAllSessionStatuses } from '@/sync/sync-context'; const formatRelativeTime = (timestamp: number): string => { const now = Date.now(); const diff = now - timestamp; - + const minutes = Math.floor(diff / (60 * 1000)); const hours = Math.floor(diff / (60 * 60 * 1000)); const days = Math.floor(diff / (24 * 60 * 60 * 1000)); - + if (minutes < 1) return 'now'; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; @@ -45,30 +46,30 @@ const formatRelativeTime = (timestamp: number): string => { interface AgentGroupItemProps { group: AgentGroup; isSelected: boolean; + isBusy: boolean; onSelect: () => void; } -const AgentGroupItem: React.FC = ({ group, isSelected, onSelect }) => { +const AgentGroupItem: React.FC = ({ group, isSelected, isBusy, onSelect }) => { const [menuOpen, setMenuOpen] = React.useState(false); const [confirmOpen, setConfirmOpen] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false); - const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup); + const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions); const handleDeleteGroup = React.useCallback(async () => { if (isDeleting) return; setIsDeleting(true); toast.info(`Deleting "${group.name}"...`); - const ok = await deleteGroup(group.name); - if (ok) { + const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true }); + if (failedIds.length === 0 && failedWorktreePaths.length === 0) { toast.success(`Deleted "${group.name}"`); } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || `Failed to delete "${group.name}"`); + toast.error(`Failed to fully delete "${group.name}"`); } setIsDeleting(false); setConfirmOpen(false); - }, [deleteGroup, group.name, isDeleting]); - + }, [deleteGroupSessions, group.name, group.sessions, isDeleting]); + return ( <>

= ({ group, isSelected, onSe type="button" className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" > - - {group.name} - +
+ + {group.name} + + {isBusy && } +
@@ -96,7 +100,7 @@ const AgentGroupItem: React.FC = ({ group, isSelected, onSe
- +
@@ -154,6 +158,7 @@ const AgentGroupItem: React.FC = ({ group, isSelected, onSe interface AgentManagerSidebarProps { className?: string; + groups: AgentGroup[]; selectedGroupName?: string | null; onGroupSelect?: (groupName: string) => void; onNewAgent?: () => void; @@ -161,36 +166,40 @@ interface AgentManagerSidebarProps { export const AgentManagerSidebar: React.FC = ({ className, + groups, selectedGroupName, onGroupSelect, onNewAgent, }) => { const [searchQuery, setSearchQuery] = React.useState(''); const [showAll, setShowAll] = React.useState(false); - - const { groups, isLoading, loadGroups } = useAgentGroupsStore(); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - - // Load groups when directory changes - React.useEffect(() => { - if (currentDirectory) { - loadGroups(); + const isLoading = useAgentGroupsStore((s) => s.isLoading); + + // Session statuses for busy indicators + const allStatuses = useAllSessionStatuses(); + const busyGroups = React.useMemo(() => { + const set = new Set(); + for (const group of groups) { + if (group.sessions.some((s) => allStatuses[s.id]?.type === 'busy')) { + set.add(group.name); + } } - }, [currentDirectory, loadGroups]); - + return set; + }, [groups, allStatuses]); + const MAX_VISIBLE = 5; - + const filteredGroups = React.useMemo(() => { if (!searchQuery.trim()) return groups; const query = searchQuery.toLowerCase(); - return groups.filter(group => + return groups.filter(group => group.name.toLowerCase().includes(query) ); }, [searchQuery, groups]); - + const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE); const remainingCount = filteredGroups.length - MAX_VISIBLE; - + return (
{/* Search Input */} @@ -205,7 +214,7 @@ export const AgentManagerSidebar: React.FC = ({ />
- + {/* New Agent Button */}
- + {/* Agent Groups Section Header */}
@@ -230,7 +239,7 @@ export const AgentManagerSidebar: React.FC = ({ )}
- + {/* Group List */} = ({ key={group.name} group={group} isSelected={selectedGroupName === group.name} + isBusy={busyGroups.has(group.name)} onSelect={() => onGroupSelect?.(group.name)} /> ))} - - {/* Show More Link */} + {!showAll && remainingCount > 0 && ( )} - - {/* Show Less Link */} + {showAll && filteredGroups.length > MAX_VISIBLE && ( )} - - {/* Empty State */} + {!isLoading && filteredGroups.length === 0 && (

diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx index 077d0875..c8f49502 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx @@ -6,10 +6,8 @@ import { AgentGroupDetail } from './AgentGroupDetail'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import type { CreateMultiRunParams } from '@/types/multirun'; interface AgentManagerViewProps { @@ -30,37 +28,37 @@ export const AgentManagerView: React.FC = ({ className }) 'connecting' | 'connected' | 'error' | 'disconnected' | undefined : 'connecting') || 'connecting' ); - const configInitialized = useConfigStore((state) => state.isInitialized); - const initializeApp = useConfigStore((state) => state.initializeApp); - const loadSessions = useSessionStore((state) => state.loadSessions); - const setDirectory = useDirectoryStore((state) => state.setDirectory); + const configInitialized = useConfigStore((s) => s.isInitialized); + const initializeApp = useConfigStore((s) => s.initializeApp); + const setDirectory = useDirectoryStore((s) => s.setDirectory); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const bootstrapAttemptAt = React.useRef(0); - const { - selectedGroupName, - selectGroup, - getSelectedGroup, - loadGroups, - } = useAgentGroupsStore(); + const groups = useAgentGroupsStore((s) => s.groups); + const selectedGroupName = useAgentGroupsStore((s) => s.selectedGroupName); + const selectGroup = useAgentGroupsStore((s) => s.selectGroup); + const loadGroups = useAgentGroupsStore((s) => s.loadGroups); - const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore(); + const createMultiRun = useMultiRunStore((s) => s.createMultiRun); + const isCreatingMultiRun = useMultiRunStore((s) => s.isLoading); + const selectedGroup = React.useMemo( + () => (selectedGroupName ? groups.find((g) => g.name === selectedGroupName) ?? null : null), + [groups, selectedGroupName], + ); + + // VS Code connection bootstrap React.useEffect(() => { - if (!isVSCodeRuntime) { - return; - } + if (!isVSCodeRuntime) return; const current = (typeof window !== 'undefined' ? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status : undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined; - if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') { - setConnectionStatus(current); - } + if (current) setConnectionStatus(current); const handler = (event: Event) => { - const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail; - const status = detail?.status; + const status = (event as CustomEvent<{ status?: string }>).detail?.status; if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') { setConnectionStatus(status); } @@ -70,14 +68,9 @@ export const AgentManagerView: React.FC = ({ className }) }, [isVSCodeRuntime]); React.useEffect(() => { - if (!isVSCodeRuntime || connectionStatus !== 'connected') { - return; - } - + if (!isVSCodeRuntime || connectionStatus !== 'connected') return; const now = Date.now(); - if (now - bootstrapAttemptAt.current < 750) { - return; - } + if (now - bootstrapAttemptAt.current < 750) return; bootstrapAttemptAt.current = now; const workspaceFolder = (typeof window !== 'undefined' @@ -85,52 +78,22 @@ export const AgentManagerView: React.FC = ({ className }) : null); if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) { - try { - setDirectory(workspaceFolder, { showOverlay: false }); - } catch { - // ignored - } + try { setDirectory(workspaceFolder, { showOverlay: false }); } catch { /* ignored */ } } - const runBootstrap = async () => { - try { - if (!configInitialized) { - await initializeApp(); - } + if (!configInitialized) void initializeApp(); + }, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, setDirectory]); - const configState = useConfigStore.getState(); - if ( - !configState.isInitialized || - !configState.isConnected || - configState.providers.length === 0 || - configState.agents.length === 0 - ) { - return; - } - - await loadSessions(); - - if (streamDebugEnabled()) { - console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', { - providers: configState.providers.length, - agents: configState.agents.length, - sessions: useSessionStore.getState().sessions.length, - }); - } - } catch { - // ignored - } - }; - - void runBootstrap(); - }, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]); + // Load groups on mount and when directory changes + React.useEffect(() => { + void loadGroups(); + }, [currentDirectory, loadGroups]); const handleGroupSelect = React.useCallback((groupName: string) => { selectGroup(groupName); }, [selectGroup]); const handleNewAgent = React.useCallback(() => { - // Clear selection to show the empty state / new agent form selectGroup(null); }, [selectGroup]); @@ -141,54 +104,30 @@ export const AgentManagerView: React.FC = ({ className }) if (result) { toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`); - const groupSlug = result.groupSlug; - - const waitForGroup = async (attempts = 6) => { - for (let attempt = 0; attempt < attempts; attempt += 1) { - await loadGroups(); - const groupsState = useAgentGroupsStore.getState(); - if (groupsState.groups.some((group) => group.name === groupSlug)) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - return false; - }; - - // Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions. - try { - await useSessionStore.getState().loadSessions(); - } catch { - // ignore - } - - await waitForGroup(); - selectGroup(groupSlug); + // Refresh groups — new worktrees + sessions now exist + await loadGroups(); + selectGroup(result.groupSlug); } else { const error = useMultiRunStore.getState().error; toast.error(error || 'Failed to create agent group'); } }, [createMultiRun, loadGroups, selectGroup]); - const selectedGroup = getSelectedGroup(); - return (

- {/* Left Sidebar - Agent Groups List */}
- - {/* Main Content Area */}
{selectedGroup ? ( ) : ( - diff --git a/packages/ui/src/components/views/git/ConflictDialog.tsx b/packages/ui/src/components/views/git/ConflictDialog.tsx index 2e6db823..7354ec58 100644 --- a/packages/ui/src/components/views/git/ConflictDialog.tsx +++ b/packages/ui/src/components/views/git/ConflictDialog.tsx @@ -9,7 +9,8 @@ import { import { Button } from '@/components/ui/button'; import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi'; @@ -33,10 +34,10 @@ export const ConflictDialog: React.FC = ({ onAbort, onClearState, }) => { - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); - const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); + const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const [isLoading, setIsLoading] = React.useState(false); diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 14090745..716c7e12 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -15,7 +15,8 @@ import { CommandList, } from '@/components/ui/command'; import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { execCommand } from '@/lib/execCommands'; import { @@ -55,7 +56,7 @@ export const IntegrateCommitsSection: React.FC<{ refreshKey, onRefresh, }) => { - const currentSessionId = useSessionStore((s) => s.currentSessionId); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); const searchInputRef = React.useRef(null); @@ -167,7 +168,7 @@ export const IntegrateCommitsSection: React.FC<{ const persistTarget = React.useCallback( (branch: string) => { if (!currentSessionId) return; - useSessionStore.getState().setWorktreeMetadata(currentSessionId, { + useSessionUIStore.getState().setWorktreeMetadata(currentSessionId, { ...worktreeMetadata, createdFromBranch: branch, }); @@ -175,7 +176,7 @@ export const IntegrateCommitsSection: React.FC<{ [currentSessionId, worktreeMetadata] ); - const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => { const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`; @@ -217,8 +218,8 @@ Important: return { visibleText, instructionsText, payloadText }; }, []); - const setPendingInputText = useSessionStore((s) => s.setPendingInputText); - const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const setPendingSyntheticParts = useInputStore((s) => s.setPendingSyntheticParts); const handleResolveWithAi = React.useCallback(( payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }, diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index a3fd649b..e82c30b4 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -51,8 +51,8 @@ import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { useUIStore } from '@/stores/useUIStore'; -import { useMessageStore } from '@/stores/messageStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; @@ -284,7 +284,7 @@ export const PullRequestSection: React.FC<{ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const { isMobile, hasTouchInput } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { @@ -633,7 +633,7 @@ export const PullRequestSection: React.FC<{ } const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const providerID = currentProviderId || lastUsedProvider?.providerID; const modelID = currentModelId || lastUsedProvider?.modelID; if (!providerID || !modelID) { @@ -656,14 +656,13 @@ export const PullRequestSection: React.FC<{ instructionsText: string, payloadText: string, ) => { - void useMessageStore.getState().sendMessage( + void useSessionUIStore.getState().sendMessage( visibleText, target.providerID, target.modelID, target.currentAgentName ?? undefined, - target.sessionId, undefined, - null, + undefined, [ { text: instructionsText, synthetic: true }, { text: payloadText, synthetic: true }, diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index 85b47868..8badbaba 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -1,7 +1,84 @@ import React, { type JSX, type ReactNode } from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import type { RuntimeAPIs } from '@/lib/api/types'; +import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types'; +import { + approxStringBytes, + evictContentLru, + setContentBytes, + touchContent as touchContentLru, + removeContentBytes, +} from '@/sync/content-cache'; + +/** Wrap a FilesAPI with an in-memory LRU content cache. */ +function withContentCache(files: FilesAPI): FilesAPI { + const cache = new Map(); + + const cachedReadFile: FilesAPI['readFile'] = files.readFile + ? async (path: string) => { + const hit = cache.get(path); + if (hit) { + touchContentLru(path); + return hit; + } + + const result = await files.readFile!(path); + const bytes = approxStringBytes(result.content); + cache.set(path, result); + setContentBytes(path, bytes); + + // Evict if over limits + const keep = new Set(); + evictContentLru(keep, (evictPath) => { + cache.delete(evictPath); + }); + + return result; + } + : undefined; + + // Invalidate cache on writes, deletes, renames + const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile + ? async (path, content) => { + cache.delete(path); + removeContentBytes(path); + return files.writeFile!(path, content); + } + : undefined; + + const cachedDelete: FilesAPI['delete'] = files.delete + ? async (path) => { + cache.delete(path); + removeContentBytes(path); + return files.delete!(path); + } + : undefined; + + const cachedRename: FilesAPI['rename'] = files.rename + ? async (oldPath, newPath) => { + cache.delete(oldPath); + removeContentBytes(oldPath); + cache.delete(newPath); + removeContentBytes(newPath); + return files.rename!(oldPath, newPath); + } + : undefined; + + return { + ...files, + readFile: cachedReadFile, + writeFile: cachedWriteFile, + delete: cachedDelete, + rename: cachedRename, + }; +} export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element { - return {children}; + const cachedApis = React.useMemo( + () => ({ + ...apis, + files: withContentCache(apis.files), + }), + [apis], + ); + return {children}; } diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 303587c4..53234568 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -1,9 +1,9 @@ import React from 'react'; import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; -import { useShallow } from 'zustand/react/shallow'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useDirectorySync, useSessionPermissions, useSessionStatus } from '@/sync/sync-context'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { useCurrentSessionActivity } from './useSessionActivity'; @@ -52,6 +52,11 @@ interface AssistantSessionMessageRecord { parts: Part[]; } +type SessionMessageRecord = { + info: Message; + parts: Part[]; +}; + const DEFAULT_WORKING: WorkingSummary = { activity: 'idle', hasWorkingContext: false, @@ -74,6 +79,9 @@ const DEFAULT_WORKING: WorkingSummary = { retryInfo: null, }; +const EMPTY_MESSAGES: Message[] = []; +const EMPTY_PARTS: Part[] = []; +const EMPTY_SESSION_MESSAGES: SessionMessageRecord[] = []; const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant'; const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning'; @@ -114,36 +122,68 @@ const getToolDisplayName = (part: ToolPart): string => { }; export function useAssistantStatus(): AssistantStatusSnapshot { - const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore( - useShallow((state) => ({ - currentSessionId: state.currentSessionId, - messages: state.messages, - permissions: state.permissions, - sessionAbortFlags: state.sessionAbortFlags, - })) + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + + const rawSessionMessages = useDirectorySync( + React.useCallback((state) => { + if (!currentSessionId) { + return EMPTY_MESSAGES; + } + return state.message[currentSessionId] ?? EMPTY_MESSAGES; + }, [currentSessionId]) + ); + + // Only subscribe to parts for the last assistant message — avoids re-render + // on every part delta for earlier messages. + const lastAssistantId = React.useMemo(() => { + for (let i = rawSessionMessages.length - 1; i >= 0; i--) { + if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id; + } + return null; + }, [rawSessionMessages]); + + const lastAssistantParts = useDirectorySync( + React.useCallback((state) => { + if (!lastAssistantId) return EMPTY_PARTS; + return state.part[lastAssistantId] ?? EMPTY_PARTS; + }, [lastAssistantId]) + ); + + const sessionMessages = React.useMemo( + () => { + if (rawSessionMessages.length === 0) { + return EMPTY_SESSION_MESSAGES; + } + return rawSessionMessages.map((msg) => ({ + info: msg, + parts: msg.id === lastAssistantId ? lastAssistantParts : EMPTY_PARTS, + })); + }, + [lastAssistantParts, rawSessionMessages, lastAssistantId] + ); + + const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? ''); + + const sessionAbortRecord = useSessionUIStore( + React.useCallback((state) => { + if (!currentSessionId) { + return null; + } + return state.sessionAbortFlags?.get(currentSessionId) ?? null; + }, [currentSessionId]) ); const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity(); - const sessionRetryAttempt = useSessionStore((state) => { - if (!currentSessionId || !state.sessionStatus) return undefined; - const s = state.sessionStatus.get(currentSessionId); - return s?.type === 'retry' ? s.attempt : undefined; - }); + const currentSessionStatus = useSessionStatus(currentSessionId ?? ''); - const sessionRetryNext = useSessionStore((state) => { - if (!currentSessionId || !state.sessionStatus) return undefined; - const s = state.sessionStatus.get(currentSessionId); - return s?.type === 'retry' ? s.next : undefined; - }); + const sessionRetryAttempt = currentSessionStatus?.type === 'retry' + ? (currentSessionStatus as { type: 'retry'; attempt?: number }).attempt + : undefined; - const sessionMessages = React.useMemo>(() => { - if (!currentSessionId) { - return []; - } - const records = messages.get(currentSessionId) ?? []; - return records as Array<{ info: Message; parts: Part[] }>; - }, [currentSessionId, messages]); + const sessionRetryNext = currentSessionStatus?.type === 'retry' + ? (currentSessionStatus as { type: 'retry'; next?: number }).next + : undefined; type ParsedStatusResult = { activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; @@ -287,11 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { }, [sessionMessages]); const abortState = React.useMemo(() => { - const sessionId = currentSessionId; - const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null; - const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged); + const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged); return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort }; - }, [currentSessionId, sessionAbortFlags]); + }, [sessionAbortRecord]); const baseWorking = React.useMemo(() => { @@ -388,9 +426,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { return baseWorking; } - const sessionId = currentSessionId; - const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : []; - const hasPendingPermission = permissionList.length > 0; + const hasPendingPermission = sessionPermissionRequests.length > 0; if (!hasPendingPermission) { return baseWorking; @@ -403,7 +439,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { canAbort: false, retryInfo: null, }; - }, [currentSessionId, permissions, baseWorking]); + }, [baseWorking, sessionPermissionRequests]); return { forming, diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 3829582c..928b46db 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -27,7 +27,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; +import { getSyncMessages, getSyncParts } from '@/sync/sync-refs'; import { useConfigStore } from '@/stores/useConfigStore'; import { useServerTTS } from './useServerTTS'; import { useSayTTS } from './useSayTTS'; @@ -120,18 +122,16 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { const isActiveRef = useRef(false); const processingMessageRef = useRef(false); const lastTranscriptRef = useRef(''); - const messagesRef = useRef }>>(new Map()); const pendingResumeOnVisibleRef = useRef(false); const pendingFinalTranscriptRef = useRef(''); const finalTranscriptTimerRef = useRef | null>(null); const deviceChangeRestartTimerRef = useRef | null>(null); // Store access - const currentSessionId = useSessionStore((s) => s.currentSessionId); - const sendMessage = useSessionStore((s) => s.sendMessage); - const setPendingInputText = useSessionStore((s) => s.setPendingInputText); - const messages = useSessionStore((s) => s.messages); - const createSession = useSessionStore((s) => s.createSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const sendMessage = useSessionUIStore((s) => s.sendMessage); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const createSession = useSessionUIStore((s) => s.createSession); const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore(); const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai'; @@ -147,16 +147,6 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { enabled: shouldCheckSayAvailability, }); - // Update messages ref when messages change - useEffect(() => { - if (currentSessionId) { - const sessionMessages = messages.get(currentSessionId); - if (sessionMessages) { - messagesRef.current = new Map(sessionMessages.map(m => [m.info.id, m])); - } - } - }, [messages, currentSessionId]); - // Stop voice when session changes to prevent microphone from staying active // This ensures voice mode doesn't carry over between sessions const prevSessionIdRef = useRef(null); @@ -374,22 +364,23 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { // Wait for AI response and speak it // We'll poll for new assistant messages const checkForResponse = async () => { - if (!isActiveRef.current) return; - - const sessionMessages = messagesRef.current; - const assistantMessages = Array.from(sessionMessages.values()) - .filter(m => m.info.role === 'assistant') + if (!isActiveRef.current || !sessionId) return; + + const rawMessages = getSyncMessages(sessionId); + const assistantMessages = rawMessages + .filter(m => m.role === 'assistant') .sort((a, b) => { - const aTime = (a.info as { time?: { created?: number } }).time?.created ?? 0; - const bTime = (b.info as { time?: { created?: number } }).time?.created ?? 0; + const aTime = (a as { time?: { created?: number } }).time?.created ?? 0; + const bTime = (b as { time?: { created?: number } }).time?.created ?? 0; return bTime - aTime; }); - + if (assistantMessages.length > 0) { const latestMessage = assistantMessages[0]; - const textParts = latestMessage.parts - .filter(p => p.type === 'text') - .map(p => p.text) + const parts = getSyncParts(latestMessage.id); + const textParts = parts + .filter((p: { type: string; text?: string }) => p.type === 'text') + .map((p: { type: string; text?: string }) => p.text ?? '') .join(' '); if (textParts.trim()) { diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index d300492b..a6830b06 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -11,8 +11,6 @@ import { import { useScrollEngine } from './useScrollEngine'; -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; - export type ContentChangeReason = 'text' | 'structural' | 'permission'; interface ChatMessageRecord { @@ -41,7 +39,6 @@ interface UseChatScrollManagerOptions { isSyncing: boolean; isMobile: boolean; chatRenderMode?: 'sorted' | 'live'; - messageStreamStates: Map; onActiveTurnChange?: (turnId: string | null) => void; } @@ -110,6 +107,7 @@ export const useChatScrollManager = ({ const lastScrollTopRef = React.useRef(0); const touchLastYRef = React.useRef(null); const pinnedSyncRafRef = React.useRef(null); + const preferInstantPinRef = React.useRef(false); const viewportAnchorTimerRef = React.useRef | null>(null); const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); @@ -178,10 +176,20 @@ export const useChatScrollManager = ({ } const distanceFromBottom = getDistanceFromBottom(); + if (distanceFromBottom <= getAutoFollowThreshold()) { + preferInstantPinRef.current = false; + return; + } + + if (preferInstantPinRef.current) { + scrollToBottomInternal({ instant: true }); + return; + } + if (distanceFromBottom > getAutoFollowThreshold()) { scrollPinnedToBottom(); } - }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]); + }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]); const schedulePinnedStateAndIndicators = React.useCallback(() => { if (typeof window === 'undefined') { @@ -264,6 +272,7 @@ export const useChatScrollManager = ({ const releasePinnedScroll = React.useCallback(() => { scrollEngine.cancelFollow(); + preferInstantPinRef.current = false; updatePinnedState(false); schedulePinnedStateAndIndicators(); }, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]); @@ -296,6 +305,7 @@ export const useChatScrollManager = ({ if (!isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom <= getPinThreshold()) { + preferInstantPinRef.current = false; updatePinnedState(true); } } @@ -412,7 +422,7 @@ export const useChatScrollManager = ({ }, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]); // Session switch - always start pinned at bottom - useIsomorphicLayoutEffect(() => { + React.useEffect(() => { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { return; } @@ -423,6 +433,7 @@ export const useChatScrollManager = ({ pendingViewportAnchorRef.current = null; // Always start pinned at bottom on session switch + preferInstantPinRef.current = true; updatePinnedState(true); setShowScrollButton(false); @@ -534,12 +545,17 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) { - onActiveTurnChange(null); return; } + let lastActiveTurnId: string | null = null; + const spy = createScrollSpy({ onActive: (turnId) => { + if (turnId === lastActiveTurnId) { + return; + } + lastActiveTurnId = turnId; onActiveTurnChange(turnId); }, }); @@ -626,7 +642,6 @@ export const useChatScrollManager = ({ container.removeEventListener('scroll', handleScroll); mutationObserver.disconnect(); spy.destroy(); - onActiveTurnChange(null); }; }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]); diff --git a/packages/ui/src/hooks/useChatSearchDirectory.ts b/packages/ui/src/hooks/useChatSearchDirectory.ts index 8a6fb67b..297bc890 100644 --- a/packages/ui/src/hooks/useChatSearchDirectory.ts +++ b/packages/ui/src/hooks/useChatSearchDirectory.ts @@ -1,13 +1,14 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import type { Session } from '@opencode-ai/sdk/v2'; export const useChatSearchDirectory = (): string | undefined => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); - const worktreeMap = useSessionStore((state) => state.worktreeMetadata); - const newSessionDraft = useSessionStore((state) => state.newSessionDraft); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); + const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); diff --git a/packages/ui/src/hooks/useEffectiveDirectory.ts b/packages/ui/src/hooks/useEffectiveDirectory.ts index 72a8ff2b..cf4d2da1 100644 --- a/packages/ui/src/hooks/useEffectiveDirectory.ts +++ b/packages/ui/src/hooks/useEffectiveDirectory.ts @@ -1,27 +1,26 @@ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import type { Session } from '@opencode-ai/sdk/v2'; /** * Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal). - * + * * Priority order: * 1. Worktree metadata path (for worktree sessions) * 2. Session directory (for active sessions) * 3. Draft session directoryOverride (when creating a new session) * 4. Fallback directory from DirectoryStore - * + * * This ensures that tabs show content from the correct project directory * even when a draft session is being created. */ export const useEffectiveDirectory = (): string | undefined => { - const { - currentSessionId, - sessions, - worktreeMetadata: worktreeMap, - newSessionDraft, - } = useSessionStore(); - const { currentDirectory: fallbackDirectory } = useDirectoryStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); + const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); // If we have an active session, use its directory if (currentSessionId) { diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts deleted file mode 100644 index 01e463d9..00000000 --- a/packages/ui/src/hooks/useEventStream.ts +++ /dev/null @@ -1,2676 +0,0 @@ -import React from 'react'; -import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; -import { saveSessionCursor } from '@/lib/messageCursorPersistence'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; -import { getMessageLimit, STUCK_SESSION_TIMEOUT_MS } from '@/stores/types/sessionTypes'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import type { Part, Session, Message } from '@opencode-ai/sdk/v2'; -import type { PermissionRequest } from '@/types/permission'; -import type { QuestionRequest } from '@/types/question'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { streamDebugEnabled } from '@/stores/utils/streamDebug'; -import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; -import { useMcpStore } from '@/stores/useMcpStore'; -import { useContextStore } from '@/stores/contextStore'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { isDesktopLocalOriginActive } from '@/lib/desktop'; -import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus'; -import { PermissionToastActions } from '@/components/chat/PermissionToastActions'; - -interface EventData { - type: string; - properties?: Record; -} - -const readStringProp = (obj: unknown, keys: string[]): string | null => { - if (!obj || typeof obj !== 'object') return null; - const record = obj as Record; - for (let i = 0; i < keys.length; i++) { - const value = record[keys[i]]; - if (typeof value === 'string' && value.length > 0) return value; - } - return null; -}; - -const readStringArrayProp = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return []; - } - - return value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -}; - -const normalizePermissionRequest = (value: unknown): PermissionRequest | null => { - if (!value || typeof value !== 'object') { - return null; - } - - const record = value as Record; - const id = readStringProp(record, ['id']); - const sessionID = readStringProp(record, ['sessionID']); - if (!id || !sessionID) { - return null; - } - - const permission = typeof record.permission === 'string' ? record.permission : ''; - const patterns = readStringArrayProp(record.patterns); - const metadata = typeof record.metadata === 'object' && record.metadata !== null - ? record.metadata as Record - : {}; - const always = readStringArrayProp(record.always); - - const toolValue = record.tool; - const tool = (toolValue && typeof toolValue === 'object') - ? { - messageID: readStringProp(toolValue, ['messageID']) ?? '', - callID: readStringProp(toolValue, ['callID']) ?? '', - } - : undefined; - - return { - id, - sessionID, - permission, - patterns, - metadata, - always, - tool: tool && tool.messageID.length > 0 && tool.callID.length > 0 ? tool : undefined, - }; -}; - -const readPermissionMetadataPreview = (metadata: Record): string => { - const preferredKeys = [ - 'command', - 'cmd', - 'script', - 'path', - 'filePath', - 'filepath', - 'file_path', - 'directory', - 'working_directory', - 'cwd', - 'url', - 'uri', - 'endpoint', - 'description', - 'action', - 'operation', - ]; - - for (let i = 0; i < preferredKeys.length; i++) { - const value = metadata[preferredKeys[i]]; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (trimmed.length > 0) { - return trimmed; - } - continue; - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - if (Array.isArray(value)) { - const joined = value - .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - .slice(0, 3) - .join(', ') - .trim(); - if (joined.length > 0) { - return joined; - } - } - } - - const metadataEntries = Object.entries(metadata); - if (metadataEntries.length === 0) { - return ''; - } - - try { - return JSON.stringify(metadata); - } catch { - return ''; - } -}; - -const buildPermissionToastBody = (request: PermissionRequest): string => { - const patterns = Array.isArray(request.patterns) ? request.patterns : []; - const patternSummary = patterns - .filter((pattern): pattern is string => typeof pattern === 'string' && pattern.trim().length > 0) - .join(', ') - .trim(); - - const metadata = typeof request.metadata === 'object' && request.metadata !== null ? request.metadata : {}; - const metadataSummary = readPermissionMetadataPreview(metadata); - - if (patternSummary.length > 0 && metadataSummary.length > 0) { - return `${patternSummary} | ${metadataSummary}`; - } - - if (patternSummary.length > 0) { - return patternSummary; - } - - if (metadataSummary.length > 0) { - return metadataSummary; - } - - const fallback = typeof request.permission === 'string' ? request.permission.trim() : ''; - return fallback.length > 0 ? fallback : 'Permission details unavailable'; -}; - -type MessageTracker = (messageId: string, event?: string, extraData?: Record) => void; - -declare global { - interface Window { - __messageTracker?: MessageTracker; - } -} - -const RESYNC_DEBOUNCE_MS = 1800; -const QUESTION_RECONCILE_COOLDOWN_MS = 3000; -const PERMISSION_RECONCILE_COOLDOWN_MS = 3000; -const DERIVED_STATE_REFRESH_COOLDOWN_MS = 2500; -const GIT_REFRESH_HINT_DEDUP_WINDOW_MS = 5000; -const GIT_REFRESH_HINT_TOOL_NAMES = new Set([ - 'edit', - 'multiedit', - 'apply_patch', - 'write', - 'file_write', - 'create', - 'bash', -]); -const GIT_REFRESH_HINT_COMPLETED_STATES = new Set([ - 'completed', - 'complete', - 'failed', - 'error', - 'cancelled', - 'canceled', -]); - -const readEventDirectory = (props: Record): string => { - const directory = readStringProp(props, ['directory']); - return directory ?? 'global'; -}; - -const MAX_MESSAGE_CACHE_SIZE = 500; -const MESSAGE_CACHE_EVICT_COUNT = 100; -const messageCache = new Map(); -const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => { - const cacheKey = `${sessionId}:${messageId}`; - const cached = messageCache.get(cacheKey); - if (cached && cached.sessionId === sessionId) { - return cached.message; - } - - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - const message = sessionMessages.find(m => m.info.id === messageId) || null; - - if (messageCache.size >= MAX_MESSAGE_CACHE_SIZE) { - // Evict oldest entries (Map preserves insertion order) - let count = 0; - for (const key of messageCache.keys()) { - if (count++ >= MESSAGE_CACHE_EVICT_COUNT) break; - messageCache.delete(key); - } - } - - messageCache.set(cacheKey, { sessionId, message }); - return message; -}; - -const getLatestMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => { - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - return sessionMessages.find((message) => message.info.id === messageId) || null; -}; - -export const useEventStream = (options?: { enabled?: boolean }) => { - const enabled = options?.enabled ?? true; - const { - addStreamingPart, - applyPartDelta, - completeStreamingMessage, - updateMessageInfo, - updateSessionCompaction, - addPermission, - dismissPermission, - addQuestion, - dismissQuestion, - currentSessionId, - applySessionMetadata, - getWorktreeMetadata, - loadMessages, - loadSessions, - updateSession, - removeSessionFromStore - } = useSessionStore(); - - const { checkConnection } = useConfigStore(); - const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); - - const activeSessionDirectory = React.useMemo(() => { - if (!currentSessionId) return undefined; - - try { - const metadata = getWorktreeMetadata?.(currentSessionId); - if (metadata?.path) return metadata.path; - } catch (error) { - console.warn('Failed to inspect worktree metadata for session directory:', error); - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionRecord = currentSessions.find((entry) => entry.id === currentSessionId); - if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { - return sessionRecord.directory.trim(); - } - - return undefined; - }, [currentSessionId, getWorktreeMetadata]); - - const effectiveDirectory = React.useMemo(() => { - if (activeSessionDirectory && activeSessionDirectory.length > 0) { - return activeSessionDirectory; - } - if (typeof fallbackDirectory === 'string' && fallbackDirectory.trim().length > 0) { - return fallbackDirectory.trim(); - } - return undefined; - }, [activeSessionDirectory, fallbackDirectory]); - - const bootstrapPendingQuestions = React.useCallback(async () => { - try { - const projects = useProjectsStore.getState().projects; - const projectDirs = projects.map((project) => project.path); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory); - - const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs]; - const pending = await opencodeClient.listPendingQuestions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - addQuestion(request as unknown as QuestionRequest); - } - } catch { - // ignored - } - }, [addQuestion, effectiveDirectory]); - - const lastQuestionRefreshAtRef = React.useRef(0); - const requestPendingQuestionsRefresh = React.useCallback((force = false) => { - const now = Date.now(); - if (!force && now - lastQuestionRefreshAtRef.current < QUESTION_RECONCILE_COOLDOWN_MS) { - return; - } - lastQuestionRefreshAtRef.current = now; - void bootstrapPendingQuestions(); - }, [bootstrapPendingQuestions]); - - const bootstrapPendingPermissions = React.useCallback(async () => { - try { - const projects = useProjectsStore.getState().projects; - const projectDirs = projects.map((project) => project.path); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory); - - const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs]; - const pending = await opencodeClient.listPendingPermissions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - const normalizedRequest = normalizePermissionRequest(request); - if (!normalizedRequest) { - continue; - } - addPermission(normalizedRequest); - } - } catch { - // ignored - } - }, [addPermission, effectiveDirectory]); - - const lastPermissionRefreshAtRef = React.useRef(0); - const requestPendingPermissionsRefresh = React.useCallback((force = false) => { - const now = Date.now(); - if (!force && now - lastPermissionRefreshAtRef.current < PERMISSION_RECONCILE_COOLDOWN_MS) { - return; - } - lastPermissionRefreshAtRef.current = now; - void bootstrapPendingPermissions(); - }, [bootstrapPendingPermissions]); - - const requestPendingPermissionsRefreshRef = React.useRef(requestPendingPermissionsRefresh); - React.useEffect(() => { - requestPendingPermissionsRefreshRef.current = requestPendingPermissionsRefresh; - }, [requestPendingPermissionsRefresh]); - - React.useEffect(() => { - if (!enabled) { - return; - } - - requestPendingPermissionsRefresh(true); - requestPendingQuestionsRefresh(true); - }, [enabled, requestPendingPermissionsRefresh, requestPendingQuestionsRefresh]); - - const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const normalized = trimmed.replace(/\\/g, '/'); - return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; - }, []); - - const resolveSessionDirectoryForStatus = React.useCallback( - (sessionId: string | null | undefined): string | null => { - if (!sessionId) return null; - try { - const metadata = getWorktreeMetadata?.(sessionId); - const metaPath = normalizeDirectory(metadata?.path ?? null); - if (metaPath) return metaPath; - } catch { - // ignored - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const record = currentSessions.find((entry) => entry.id === sessionId); - return normalizeDirectory((record as { directory?: string | null })?.directory ?? null); - }, - [getWorktreeMetadata, normalizeDirectory] - ); - - const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus); - const lastStatusRef = React.useRef<{ status: EventStreamStatus; hint: string | null } | null>(null); - - const publishStatus = React.useCallback( - (status: EventStreamStatus, hint?: string | null) => { - const normalizedHint = hint ?? null; - const last = lastStatusRef.current; - if (last && last.status === status && last.hint === normalizedHint) { - return; - } - - lastStatusRef.current = { status, hint: normalizedHint }; - - if (streamDebugEnabled()) { - const prefixMap: Record = { - idle: '[IDLE]', - connecting: '[CONNECT]', - connected: '[CONNECTED]', - reconnecting: '[RECONNECT]', - paused: '[PAUSED]', - offline: '[OFFLINE]', - error: '[ERROR]' - }; - - const prefix = prefixMap[status] ?? '[INFO]'; - const message = normalizedHint ? `${prefix} SSE ${status}: ${normalizedHint}` : `${prefix} SSE ${status}`; - console.info(message); - } - - setEventStreamStatus(status, normalizedHint); - }, - [setEventStreamStatus] - ); - - const resyncMessages = React.useCallback( - (sessionId: string, reason: string, limit?: number) => { - if (!sessionId) { - return Promise.resolve(); - } - const now = Date.now(); - if (resyncInFlightRef.current) { - return resyncInFlightRef.current; - } - if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) { - return Promise.resolve(); - } - const task = loadMessages(sessionId, limit) - .catch((error) => { - console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error); - }) - .finally(() => { - resyncInFlightRef.current = null; - lastResyncAtRef.current = Date.now(); - }); - resyncInFlightRef.current = task; - return task; - }, - [loadMessages] - ); - - const bootstrapState = React.useCallback( - async (reason: string) => { - if (streamDebugEnabled()) { - console.info('[useEventStream] Bootstrapping state:', reason); - } - try { - const activeLimit = getMessageLimit(); - await Promise.all([ - loadSessions(), - currentSessionId ? resyncMessages(currentSessionId, reason, activeLimit) : Promise.resolve(), - ]); - } catch (error) { - console.warn('[useEventStream] Bootstrap failed:', reason, error); - } - }, - [currentSessionId, loadSessions, resyncMessages] - ); - - const scheduleSoftResync = React.useCallback( - (sessionId: string, reason: string, limit = getMessageLimit()): Promise => { - if (!sessionId) return Promise.resolve(); - - const memory = useSessionStore.getState().sessionMemoryState.get(sessionId); - const cooldownUntil = memory?.streamingCooldownUntil; - const now = Date.now(); - if (typeof cooldownUntil === 'number' && cooldownUntil > now) { - const delay = Math.min(3000, Math.max(0, cooldownUntil - now)); - return new Promise((resolve) => { - setTimeout(() => { - resyncMessages(sessionId, reason, limit).finally(resolve); - }, delay); - }); - } - - return resyncMessages(sessionId, reason, limit); - }, - [resyncMessages] - ); - - React.useEffect(() => { - scheduleSoftResyncRef.current = scheduleSoftResync; - }, [scheduleSoftResync]); - - const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record) => { - if (streamDebugEnabled()) { - console.debug(`[MessageTracker] ${messageId}: ${event}`, extraData); - } - }, []); - - const reportMessage = React.useCallback((messageId: string) => { - if (streamDebugEnabled()) { - console.debug(`[MessageTracker] ${messageId}: reported`); - } - }, []); - - const unsubscribeRef = React.useRef<(() => void) | null>(null); - const reconnectTimeoutRef = React.useRef(null); - const reconnectAttemptsRef = React.useRef(0); - const missingMessageHydrationRef = React.useRef>(new Set()); - const metadataRefreshTimestampsRef = React.useRef>(new Map()); - const sessionRefreshTimeoutRef = React.useRef(null); - const isCleaningUpRef = React.useRef(false); - const resyncInFlightRef = React.useRef | null>(null); - const lastResyncAtRef = React.useRef(0); - const permissionToastShownRef = React.useRef>(new Set()); - const questionToastShownRef = React.useRef>(new Set()); - const notifiedMessagesRef = React.useRef>(new Set()); - const notifiedQuestionsRef = React.useRef>(new Set()); - const serverNotificationEventSeenRef = React.useRef(false); - const modeSwitchToastShownRef = React.useRef>(new Set()); - const lastUserAgentSelectionRef = React.useRef>(new Map()); - - const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { - if (typeof document === 'undefined') return 'visible'; - - const state = document.visibilityState; - return state === 'hidden' && document.hasFocus() ? 'visible' : state; - }, []); - - const visibilityStateRef = React.useRef<'visible' | 'hidden'>(resolveVisibilityState()); - const onlineStatusRef = React.useRef(typeof navigator === 'undefined' ? true : navigator.onLine); - const pendingResumeRef = React.useRef(false); - const pauseTimeoutRef = React.useRef(null); - const staleCheckIntervalRef = React.useRef(null); - const lastEventTimestampRef = React.useRef(Date.now()); - const lastMessageEventBySessionRef = React.useRef>(new Map()); - const pendingMessageStallTimersRef = React.useRef>(new Map()); - const lastMessageStallRecoveryBySessionRef = React.useRef>(new Map()); - const partTypeHintsByKeyRef = React.useRef>(new Map()); - const sessionCooldownTimersRef = React.useRef>(new Map()); - const sessionActivityPhaseRef = React.useRef>(new Map()); - const sessionActivityLastRefreshAtRef = React.useRef(0); - const sessionActivityRefreshInFlightRef = React.useRef | null>(null); - const lastDerivedActivityRepairAtRef = React.useRef(0); - const lastDerivedStatusRepairAtRef = React.useRef(0); - const lastGitRefreshHintAtRef = React.useRef>(new Map()); - const scheduleSoftResyncRef = React.useRef< - (sessionId: string, reason: string, limit?: number) => Promise - >(() => Promise.resolve()); - const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {}); - - const writePartTypeHint = React.useCallback((key: string, type: string) => { - const map = partTypeHintsByKeyRef.current; - map.set(key, type); - if (map.size > 4000) { - const firstKey = map.keys().next().value; - if (typeof firstKey === 'string') { - map.delete(firstKey); - } - } - }, []); - - const isNotificationContextHidden = React.useCallback((isVSCodeRuntime: boolean): boolean => { - if (visibilityStateRef.current === 'hidden') { - return true; - } - if (isVSCodeRuntime && typeof document !== 'undefined') { - return !document.hasFocus(); - } - return false; - }, []); - - const dispatchRuntimeNotification = React.useCallback((payload: { - title: string; - body?: string; - tag?: string; - requireHidden?: boolean; - }) => { - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (!runtimeAPIs?.notifications) { - return; - } - - const title = typeof payload.title === 'string' ? payload.title.trim() : ''; - if (!title) { - return; - } - - const settings = useUIStore.getState(); - if (!settings.nativeNotificationsEnabled) { - return; - } - - const isVSCodeRuntime = Boolean(runtimeAPIs.runtime?.isVSCode); - const shouldRequireHidden = Boolean(payload.requireHidden) || settings.notificationMode === 'hidden-only'; - if (shouldRequireHidden && !isNotificationContextHidden(isVSCodeRuntime)) { - return; - } - - void runtimeAPIs.notifications.notifyAgentCompletion({ - title, - body: typeof payload.body === 'string' ? payload.body : '', - tag: typeof payload.tag === 'string' ? payload.tag : undefined, - }); - }, [isNotificationContextHidden]); - - const maybeBootstrapIfStale = React.useCallback( - (reason: string) => { - const now = Date.now(); - if (now - lastEventTimestampRef.current > 25000) { - void bootstrapState(reason); - lastEventTimestampRef.current = now; - } - }, - [bootstrapState] - ); - - const emitGitRefreshHint = React.useCallback((params: { - directory: string; - sessionId: string; - messageId: string; - partId?: string | null; - toolName: string; - toolState: string; - }) => { - if (typeof window === 'undefined') { - return; - } - - const dedupKey = `${params.sessionId}:${params.messageId}:${params.partId ?? 'unknown'}:${params.toolName}:${params.toolState}`; - const now = Date.now(); - const lastAt = lastGitRefreshHintAtRef.current.get(dedupKey) ?? 0; - if (now - lastAt < GIT_REFRESH_HINT_DEDUP_WINDOW_MS) { - return; - } - - lastGitRefreshHintAtRef.current.set(dedupKey, now); - if (lastGitRefreshHintAtRef.current.size > 600) { - const firstKey = lastGitRefreshHintAtRef.current.keys().next().value; - if (typeof firstKey === 'string') { - lastGitRefreshHintAtRef.current.delete(firstKey); - } - } - - window.dispatchEvent(new CustomEvent('openchamber:git-refresh-hint', { - detail: { - directory: params.directory, - sessionId: params.sessionId, - messageId: params.messageId, - toolName: params.toolName, - toolState: params.toolState, - }, - })); - }, []); - - - const currentSessionIdRef = React.useRef(currentSessionId); - const previousSessionIdRef = React.useRef(null); - const previousSessionDirectoryRef = React.useRef(null); - React.useEffect(() => { - currentSessionIdRef.current = currentSessionId; - }, [currentSessionId]); - - const requestSessionMetadataRefresh = React.useCallback( - (sessionId: string | undefined | null, directoryOverride?: string | null) => { - if (!sessionId) return; - - const now = Date.now(); - const timestamps = metadataRefreshTimestampsRef.current; - const lastRefresh = timestamps.get(sessionId); - - if (lastRefresh && now - lastRefresh < 3000) return; - - timestamps.set(sessionId, now); - - const resolveDirectoryForSession = (id: string): string | null => { - if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) { - return directoryOverride.trim(); - } - - try { - const metadata = getWorktreeMetadata?.(id); - if (metadata?.path) { - return metadata.path; - } - } catch { - // ignored - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionRecord = currentSessions.find((entry) => entry.id === id) as Session & { directory?: string | null }; - if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { - return sessionRecord.directory.trim(); - } - - return null; - }; - - setTimeout(async () => { - try { - const directory = resolveDirectoryForSession(sessionId); - const session = directory - ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId)) - : await opencodeClient.getSession(sessionId); - - if (session) { - const patch: Partial = {}; - if (typeof session.title === 'string' && session.title.length > 0) { - patch.title = session.title; - } - if (session.summary !== undefined) { - patch.summary = session.summary; - } - if (Object.keys(patch).length > 0) { - applySessionMetadata(sessionId, patch); - } - } - } catch (error) { - console.warn('Failed to refresh session metadata:', error); - } - }, 100); - }, - [applySessionMetadata, getWorktreeMetadata] - ); - - type SessionStatusPayload = { - type: 'idle' | 'busy' | 'retry'; - attempt?: number; - message?: string; - next?: number; - }; - - const updateSessionStatus = React.useCallback(( - sessionId: string, - status: SessionStatusPayload, - source: string = 'unknown' - ) => { - if (!sessionId) return; - - const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const prevType = storeStatus?.type ?? 'idle'; - const nextType = status?.type ?? 'idle'; - - // Note: needs_attention logic is now handled by the server - // Server maintains authoritative state based on view tracking and message events - - if (process.env.NODE_ENV === 'development' && prevType !== nextType) { - try { - console.info('[SESSION-STATUS]', { - sessionId, - from: prevType, - to: nextType, - source, - ...(nextType === 'retry' - ? { - attempt: status.attempt, - next: status.next, - message: status.message, - } - : {}), - }); - } catch { - // ignore - } - } - - const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry'); - const shouldDisarmMessageStallCheck = nextType === 'idle'; - - if (shouldDisarmMessageStallCheck) { - const pending = pendingMessageStallTimersRef.current.get(sessionId); - if (pending) { - clearTimeout(pending); - pendingMessageStallTimersRef.current.delete(sessionId); - } - } - - if (shouldArmMessageStallCheck) { - const pending = pendingMessageStallTimersRef.current.get(sessionId); - if (pending) { - clearTimeout(pending); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const startAt = Date.now(); - const timer = setTimeout(() => { - const current = useSessionStore.getState().sessionStatus?.get(sessionId); - if (current?.type !== 'busy' && current?.type !== 'retry') { - return; - } - - const lastRecoveryAt = lastMessageStallRecoveryBySessionRef.current.get(sessionId) ?? 0; - if (Date.now() - lastRecoveryAt < 15000) { - return; - } - - const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0; - if (lastMsgAt >= startAt) { - return; - } - - lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now()); - void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getMessageLimit()) - .finally(() => { - scheduleReconnectRef.current('No message events after busy status'); - }); - }, 2000); - - pendingMessageStallTimersRef.current.set(sessionId, timer); - } - - const next = new Map(useSessionStore.getState().sessionStatus ?? new Map()); - if (nextType === 'idle') { - next.set(sessionId, { ...status, confirmedAt: Date.now() }); - } else { - const existing = next.get(sessionId); - if (existing?.confirmedAt) { - next.set(sessionId, { ...status, confirmedAt: existing.confirmedAt }); - } else { - next.set(sessionId, status); - } - } - useSessionStore.setState({ sessionStatus: next }); - }, []); - - const updateSessionActivityPhase = React.useCallback(( - sessionId: string, - phase: 'idle' | 'busy' | 'cooldown', - source: string = 'unknown', - options?: { syncStatus?: boolean } - ) => { - if (!sessionId) return; - const syncStatus = options?.syncStatus !== false; - - const current = sessionActivityPhaseRef.current.get(sessionId); - if (current === phase) { - return; - } - - const existingTimer = sessionCooldownTimersRef.current.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionCooldownTimersRef.current.delete(sessionId); - } - - const next = new Map(sessionActivityPhaseRef.current); - next.set(sessionId, phase); - sessionActivityPhaseRef.current = next; - - if (!syncStatus) { - return; - } - - if (phase === 'idle') { - updateSessionStatus(sessionId, { type: 'idle' }, `${source}:idle`); - return; - } - - updateSessionStatus(sessionId, { type: 'busy' }, `${source}:${phase}`); - - if (phase === 'cooldown') { - const timer = setTimeout(() => { - sessionCooldownTimersRef.current.delete(sessionId); - if (sessionActivityPhaseRef.current.get(sessionId) !== 'cooldown') { - return; - } - const latest = new Map(sessionActivityPhaseRef.current); - latest.set(sessionId, 'idle'); - sessionActivityPhaseRef.current = latest; - updateSessionStatus(sessionId, { type: 'idle' }, `${source}:cooldown_timeout`); - }, 2000); - sessionCooldownTimersRef.current.set(sessionId, timer); - } - }, [updateSessionStatus]); - - const refreshSessionActivityStatus = React.useCallback(async () => { - const now = Date.now(); - if (sessionActivityRefreshInFlightRef.current) { - return sessionActivityRefreshInFlightRef.current; - } - if (now - sessionActivityLastRefreshAtRef.current < 1500) { - return; - } - sessionActivityLastRefreshAtRef.current = now; - - const applyStatusMap = (statusMap: Record) => { - const observed = new Set(); - const currentSessions = useSessionStore.getState().sessions; - const knownSessionIds = new Set(currentSessions.map((session) => session.id)); - - for (const [sessionId, raw] of Object.entries(statusMap)) { - if (!sessionId || !raw) continue; - observed.add(sessionId); - const phase: 'idle' | 'busy' | 'cooldown' = - raw.type === 'cooldown' - ? 'cooldown' - : raw.type === 'busy' || raw.type === 'retry' - ? 'busy' - : 'idle'; - updateSessionActivityPhase(sessionId, phase, 'snapshot'); - } - - for (const [sessionId, phase] of sessionActivityPhaseRef.current.entries()) { - if (!knownSessionIds.has(sessionId)) continue; - if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) { - updateSessionActivityPhase(sessionId, 'idle', 'snapshot_missing'); - } - } - }; - - const task = (async (): Promise => { - try { - const webServerActivity = await opencodeClient.getWebServerSessionActivity(); - if (webServerActivity && Object.keys(webServerActivity).length > 0) { - applyStatusMap(webServerActivity); - return; - } - - const globalStatusMap = await opencodeClient.getGlobalSessionStatus(); - if (globalStatusMap && Object.keys(globalStatusMap).length > 0) { - applyStatusMap(globalStatusMap); - } - } catch { - // ignored - } - })().finally(() => { - sessionActivityRefreshInFlightRef.current = null; - }); - - sessionActivityRefreshInFlightRef.current = task; - return task; - }, [updateSessionActivityPhase]); - - const clearSessionActivityTimers = React.useCallback(() => { - const cooldownTimers = sessionCooldownTimersRef.current; - for (const timer of cooldownTimers.values()) { - clearTimeout(timer); - } - cooldownTimers.clear(); - sessionActivityPhaseRef.current.clear(); - }, []); - - const repairSessionDerivedState = React.useCallback(( - reason: string, - options?: { refreshActivity?: boolean; pollStatus?: boolean; immediate?: boolean } - ) => { - const refreshActivity = options?.refreshActivity !== false; - const pollStatus = options?.pollStatus !== false; - const immediate = options?.immediate === true; - const now = Date.now(); - - if (streamDebugEnabled()) { - console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus, immediate }); - } - - if (refreshActivity) { - if (immediate || now - lastDerivedActivityRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) { - lastDerivedActivityRepairAtRef.current = now; - void refreshSessionActivityStatus(); - } - } - - if (pollStatus) { - if (immediate || now - lastDerivedStatusRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) { - lastDerivedStatusRepairAtRef.current = now; - triggerSessionStatusPoll(); - } - } - }, [refreshSessionActivityStatus]); - - React.useEffect(() => { - const nextSessionId = currentSessionId ?? null; - const prevSessionId = previousSessionIdRef.current; - const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId); - const prevDirectory = previousSessionDirectoryRef.current; - - if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) { - // Clear the message cache on session switch to free memory - messageCache.clear(); - - if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) { - repairSessionDerivedState('session_switch_directory'); - } - } - - previousSessionIdRef.current = nextSessionId; - previousSessionDirectoryRef.current = nextDirectory; - }, [currentSessionId, repairSessionDerivedState, resolveSessionDirectoryForStatus]); - - const handleEvent = React.useCallback((event: EventData) => { - lastEventTimestampRef.current = Date.now(); - - if (streamDebugEnabled()) { - console.debug('[useEventStream] Received event:', event.type, event.properties); - } - - if (!event.properties) return; - - const props = event.properties as Record; - const nonMetadataSessionEvents = new Set(['session.abort', 'session.error']); - - if (!nonMetadataSessionEvents.has(event.type)) { - const sessionPayload = (typeof props.session === 'object' && props.session !== null ? props.session : null) || - (typeof props.sessionInfo === 'object' && props.sessionInfo !== null ? props.sessionInfo : null) as Record | null; - - if (sessionPayload) { - const sessionPayloadAny = sessionPayload as Record; - const sessionId = (typeof sessionPayloadAny.id === 'string' && sessionPayloadAny.id.length > 0) ? sessionPayloadAny.id : - (typeof sessionPayloadAny.sessionID === 'string' && sessionPayloadAny.sessionID.length > 0) ? sessionPayloadAny.sessionID : - (typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID : - (typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined; - - if (sessionId) { - const titleCandidate = typeof sessionPayloadAny.title === 'string' ? sessionPayloadAny.title : - typeof props.title === 'string' ? props.title : undefined; - - const summaryCandidate = (typeof sessionPayloadAny.summary === 'object' && sessionPayloadAny.summary !== null) ? sessionPayloadAny.summary as Session['summary'] : - (typeof props.summary === 'object' && props.summary !== null) ? props.summary as Session['summary'] : undefined; - - if (titleCandidate !== undefined || summaryCandidate !== undefined) { - const patch: Partial = {}; - if (titleCandidate !== undefined) patch.title = titleCandidate; - if (summaryCandidate !== undefined) patch.summary = summaryCandidate; - applySessionMetadata(sessionId, patch); - } - } - } - } - - switch (event.type) { - case 'server.connected': - checkConnection(); - break; - case 'global.disposed': - case 'server.instance.disposed': { - void bootstrapState('server_disposed_event'); - break; - } - - case 'mcp.tools.changed': { - const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory; - void useMcpStore.getState().refresh({ directory: directory ?? null, silent: true }); - break; - } - - case 'session.status': - { - const sessionId = readStringProp(props, ['sessionID', 'sessionId']); - const statusRaw = (props as { status?: unknown }).status; - const statusObj = (typeof statusRaw === 'object' && statusRaw !== null) ? statusRaw as Record : null; - const statusType = - typeof statusRaw === 'string' - ? statusRaw - : typeof statusObj?.type === 'string' - ? statusObj.type - : typeof statusObj?.status === 'string' - ? statusObj.status - : typeof (props as { type?: unknown }).type === 'string' - ? ((props as { type: string }).type) - : typeof (props as { phase?: unknown }).phase === 'string' - ? ((props as { phase: string }).phase) - : typeof (props as { state?: unknown }).state === 'string' - ? ((props as { state: string }).state) - : null; - const statusInfo = statusObj ?? ({} as Record); - const metadata = (props as { metadata?: unknown }).metadata; - const metadataObj = (typeof metadata === 'object' && metadata !== null) ? metadata as Record : null; - - if (sessionId && statusType) { - if (statusType === 'busy') { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false }); - } else if (statusType === 'retry') { - updateSessionStatus(sessionId, { - type: 'retry', - attempt: - typeof statusInfo.attempt === 'number' - ? statusInfo.attempt - : typeof (props as { attempt?: unknown }).attempt === 'number' - ? (props as { attempt: number }).attempt - : typeof metadataObj?.attempt === 'number' - ? metadataObj.attempt - : undefined, - message: - typeof statusInfo.message === 'string' - ? statusInfo.message - : typeof (props as { message?: unknown }).message === 'string' - ? (props as { message: string }).message - : typeof metadataObj?.message === 'string' - ? metadataObj.message - : undefined, - next: - typeof statusInfo.next === 'number' - ? statusInfo.next - : typeof (props as { next?: unknown }).next === 'number' - ? (props as { next: number }).next - : typeof metadataObj?.next === 'number' - ? metadataObj.next - : undefined, - }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false }); - } else { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'idle', 'sse:session.status', { syncStatus: false }); - repairSessionDerivedState('session.status_idle', { refreshActivity: false }); - } - requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); - } - } - break; - - case 'openchamber:session-activity': - { - const sessionId = readStringProp(props, ['sessionId', 'sessionID']); - const phase = typeof props.phase === 'string' ? props.phase : null; - if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { - updateSessionActivityPhase(sessionId, phase, 'sse:openchamber:session-activity'); - requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); - } - } - break; - - case 'openchamber:session-status': - { - const sessionId = readStringProp(props, ['sessionId', 'sessionID']); - const status = typeof props.status === 'string' ? props.status : null; - const needsAttention = typeof props.needsAttention === 'boolean' ? props.needsAttention : false; - const timestamp = typeof props.timestamp === 'number' ? props.timestamp : Date.now(); - - if (sessionId && status) { - // Update session status - if (status === 'busy') { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false }); - } else if (status === 'retry') { - const metadata = (typeof props.metadata === 'object' && props.metadata !== null) ? props.metadata as Record : {}; - updateSessionStatus(sessionId, { - type: 'retry', - attempt: typeof metadata.attempt === 'number' ? metadata.attempt : undefined, - message: typeof metadata.message === 'string' ? metadata.message : undefined, - next: typeof metadata.next === 'number' ? metadata.next : undefined, - }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false }); - } else { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'idle', 'sse:openchamber:session-status', { syncStatus: false }); - if (needsAttention) { - repairSessionDerivedState('openchamber.session-status_attention_idle', { refreshActivity: false }); - } - } - - // Update attention state in the same update to ensure atomicity - const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); - const newAttentionStates = new Map(currentAttentionStates); - const existing = newAttentionStates.get(sessionId); - - newAttentionStates.set(sessionId, { - needsAttention, - lastStatusChangeAt: timestamp, - lastUserMessageAt: existing?.lastUserMessageAt ?? null, - status: status as 'idle' | 'busy' | 'retry', - isViewed: existing?.isViewed ?? false, - }); - - useSessionStore.setState({ sessionAttentionStates: newAttentionStates }); - } - } - break; - - case 'message.part.updated': { - const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null; - if (!part) break; - - const partExt = part as Record; - const messageInfo = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record) : props; - - const messageInfoSessionId = readStringProp(messageInfo, ['sessionID', 'sessionId']); - - const resolvedSessionId = - readStringProp(partExt, ['sessionID', 'sessionId']) || - messageInfoSessionId || - readStringProp(props, ['sessionID', 'sessionId']); - - const messageInfoId = readStringProp(messageInfo, ['messageID', 'messageId', 'id']); - - const resolvedMessageId = - readStringProp(partExt, ['messageID', 'messageId']) || - messageInfoId || - readStringProp(props, ['messageID', 'messageId']); - - if (!resolvedSessionId || !resolvedMessageId) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.part.updated without resolvable session/message id', { - sessionID: partExt.sessionID ?? messageInfoSessionId ?? props.sessionID, - messageID: partExt.messageID ?? messageInfoId ?? props.messageID, - }); - } - break; - } - - const sessionId = resolvedSessionId; - const messageId = resolvedMessageId; - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const shouldKeepSyntheticUserText = (value: unknown): boolean => { - const text = typeof value === 'string' ? value.trim() : ''; - if (!text) return false; - return ( - text.startsWith('User has requested to enter plan mode') || - text.startsWith('The plan at ') || - text.startsWith('The following tool was executed by the user') - ); - }; - - const inferUserRoleFromPart = (): boolean => { - const partType = typeof partExt.type === 'string' ? partExt.type : ''; - if (partType === 'subtask' || partType === 'agent' || partType === 'file') { - return true; - } - if (partType === 'text' && partExt.synthetic === true) { - const text = (partExt as { text?: unknown }).text; - return shouldKeepSyntheticUserText(text); - } - return false; - }; - - let roleInfo = 'assistant'; - const existingMessage = getLatestMessageFromStore(sessionId, messageId); - const existingPartForType = existingMessage?.parts?.find((item) => item?.id === partExt.id); - const existingPartType = typeof (existingPartForType as { type?: unknown } | undefined)?.type === 'string' - ? (existingPartForType as { type: string }).type - : undefined; - if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') { - roleInfo = (messageInfo as { role?: string }).role as string; - } else { - if (existingMessage) { - const existingRole = (existingMessage.info as Record).role; - if (typeof existingRole === 'string') { - roleInfo = existingRole; - } - } - } - - if (roleInfo !== 'user' && inferUserRoleFromPart()) { - roleInfo = 'user'; - } - - trackMessage(messageId, 'part_received', { role: roleInfo }); - - if (roleInfo === 'user' && partExt.synthetic === true) { - const text = (partExt as { text?: unknown }).text; - if (!shouldKeepSyntheticUserText(text)) { - trackMessage(messageId, 'skipped_synthetic_user_part'); - break; - } - } - - const updatedPartId = readStringProp(partExt, ['id', 'partID', 'partId']); - const directory = readEventDirectory(props); - const partTypeHintKey = updatedPartId ? `${directory}:${messageId}:${updatedPartId}` : null; - const hintedPartType = partTypeHintKey ? partTypeHintsByKeyRef.current.get(partTypeHintKey) : undefined; - - const resolvedPartType = - part.type || - existingPartType || - hintedPartType || - 'text'; - - const messagePartBase: Part = { - ...part, - type: resolvedPartType, - } as Part; - - const messagePart: Part = { - ...messagePartBase, - } as Part; - - if (partTypeHintKey && typeof resolvedPartType === 'string' && resolvedPartType.length > 0) { - writePartTypeHint(partTypeHintKey, resolvedPartType); - } - - if (roleInfo === 'assistant') { - const partType = (messagePart as { type?: unknown }).type; - const partTime = (messagePart as { time?: { end?: unknown } }).time; - const partHasEnded = typeof partTime?.end === 'number'; - const toolState = (messagePart as { state?: { status?: unknown } }).state?.status; - const normalizedToolState = typeof toolState === 'string' ? toolState.toLowerCase() : null; - const toolName = typeof (messagePart as { tool?: unknown }).tool === 'string' - ? (messagePart as { tool: string }).tool.toLowerCase() - : null; - const textContent = (messagePart as { text?: unknown }).text; - - if ( - partType === 'tool' - && toolName - && GIT_REFRESH_HINT_TOOL_NAMES.has(toolName) - && normalizedToolState - && GIT_REFRESH_HINT_COMPLETED_STATES.has(normalizedToolState) - ) { - emitGitRefreshHint({ - directory, - sessionId, - messageId, - partId: updatedPartId, - toolName, - toolState: normalizedToolState, - }); - } - - if (partType === 'tool' && toolName === 'question') { - requestPendingQuestionsRefresh(); - } - - const isStreamingPart = (() => { - if (partType === 'tool') { - return normalizedToolState === 'running' || normalizedToolState === 'pending'; - } - if (partType === 'reasoning') { - return !partHasEnded; - } - if (partType === 'text') { - const hasText = typeof textContent === 'string' && textContent.trim().length > 0; - return hasText && !partHasEnded; - } - if (partType === 'step-start') { - return true; - } - return false; - })(); - - if (isStreamingPart) { - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const recentlyConfirmedIdle = - currentStatus?.type === 'idle' && - typeof currentStatus.confirmedAt === 'number' && - Date.now() - currentStatus.confirmedAt < 1200; - if (!currentStatus || currentStatus.type === 'idle') { - if (!recentlyConfirmedIdle) { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated'); - } - } - } - } - - trackMessage(messageId, 'addStreamingPart_called'); - addStreamingPart(sessionId, messageId, messagePart, roleInfo); - break; - } - - case 'message.part.delta': { - const sessionId = readStringProp(props, ['sessionID', 'sessionId']); - const messageId = readStringProp(props, ['messageID', 'messageId']); - const partId = readStringProp(props, ['partID', 'partId']); - const field = readStringProp(props, ['field']); - const delta = typeof props.delta === 'string' ? props.delta : null; - - if (!sessionId || !messageId || !partId || !field || delta === null) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.part.delta with missing payload', { - sessionID: props.sessionID, - messageID: props.messageID, - partID: props.partID, - field: props.field, - }); - } - break; - } - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const existingMessage = getLatestMessageFromStore(sessionId, messageId); - const existingPart = existingMessage?.parts?.find((item) => item?.id === partId); - const existingRole = (existingMessage?.info as Record | undefined)?.role; - const roleInfo = typeof existingRole === 'string' ? existingRole : 'assistant'; - - if (!existingPart) { - if (field === 'text' || field === 'content' || field === 'value') { - const directory = readEventDirectory(props); - const deltaPartTypeHint = - readStringProp(props, ['partType', 'type', 'part_type']) || - readStringProp(props, ['kind']); - const partTypeHintKey = `${directory}:${messageId}:${partId}`; - const hintedPartType = partTypeHintsByKeyRef.current.get(partTypeHintKey); - const bootstrappedPartType = - typeof deltaPartTypeHint === 'string' && deltaPartTypeHint.trim().length > 0 - ? deltaPartTypeHint - : (typeof hintedPartType === 'string' && hintedPartType.trim().length > 0 - ? hintedPartType - : 'text'); - - const bootstrappedPart = { - id: partId, - type: bootstrappedPartType, - sessionID: sessionId, - messageID: messageId, - delta, - [field]: '', - } as unknown as Part; - - if (typeof bootstrappedPartType === 'string' && bootstrappedPartType.length > 0) { - writePartTypeHint(partTypeHintKey, bootstrappedPartType); - } - - addStreamingPart(sessionId, messageId, bootstrappedPart, roleInfo); - } - break; - } - - if (roleInfo === 'assistant' && delta.length > 0) { - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const recentlyConfirmedIdle = - currentStatus?.type === 'idle' && - typeof currentStatus.confirmedAt === 'number' && - Date.now() - currentStatus.confirmedAt < 1200; - if (!currentStatus || currentStatus.type === 'idle') { - if (!recentlyConfirmedIdle) { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.delta'); - } - } - } - - trackMessage(messageId, 'part_delta_received', { role: roleInfo, field }); - applyPartDelta(sessionId, messageId, partId, field, delta, roleInfo); - break; - } - - case 'message.updated': { - const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record) : props; - const messageExt = message as Record; - - const resolvedSessionId = - readStringProp(messageExt, ['sessionID', 'sessionId']) || - readStringProp(props, ['sessionID', 'sessionId']); - - const resolvedMessageId = - readStringProp(messageExt, ['messageID', 'messageId', 'id']) || - readStringProp(props, ['messageID', 'messageId']); - - if (!resolvedSessionId || !resolvedMessageId) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.updated without resolvable session/message id', { - sessionID: messageExt.sessionID ?? props.sessionID, - messageID: messageExt.id ?? props.messageID, - }); - } - break; - } - - const sessionId = resolvedSessionId; - const messageId = resolvedMessageId; - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - if (streamDebugEnabled()) { - try { - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || []; - const textParts = Array.isArray(serverParts) - ? serverParts.filter((p: unknown) => (p as { type?: string })?.type === 'text') - : []; - const textJoined = textParts - .map((p: unknown) => { - const part = p as { text?: string; content?: string }; - return typeof part?.text === 'string' ? part.text : typeof part?.content === 'string' ? part.content : ''; - }) - .join('\n'); - console.info('[STREAM-TRACE] message.updated', { - messageId, - role: (messageExt as { role?: unknown }).role, - status: (messageExt as { status?: unknown }).status, - textLen: textJoined.length, - textPreview: textJoined.slice(0, 120), - partsCount: Array.isArray(serverParts) ? serverParts.length : 0, - }); - } catch { /* ignored */ } - } - - trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role }); - - if ((messageExt as { role?: unknown }).role === 'user') { - // Update lastUserMessageAt in session memory state - const { sessionMemoryState } = useMessageStore.getState(); - const currentMemory = sessionMemoryState.get(sessionId); - if (currentMemory) { - const newMemoryState = new Map(sessionMemoryState); - newMemoryState.set(sessionId, { - ...currentMemory, - lastUserMessageAt: Date.now(), - }); - useMessageStore.setState({ sessionMemoryState: newMemoryState }); - } - - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; - const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; - const existingUserMessage = getMessageFromStore(sessionId, messageId); - - const agentCandidate = (() => { - const rawAgent = (messageExt as { agent?: unknown }).agent; - if (typeof rawAgent === 'string' && rawAgent.trim().length > 0) return rawAgent.trim(); - const rawMode = (messageExt as { mode?: unknown }).mode; - if (typeof rawMode === 'string' && rawMode.trim().length > 0) return rawMode.trim(); - return ''; - })(); - - const createdAt = (() => { - const rawTime = (messageExt as { time?: unknown }).time as { created?: unknown } | undefined; - const created = rawTime?.created; - return typeof created === 'number' ? created : null; - })(); - - const isSyntheticOnly = - partsArray.length > 0 && - partsArray.every((part) => (part as unknown as { synthetic?: boolean })?.synthetic === true); - - const shouldApplyUserAgentSelection = (() => { - if (!agentCandidate) return false; - - // Mode switches are server-injected synthetic user messages; always accept. - if (isSyntheticOnly && (agentCandidate === 'plan' || agentCandidate === 'build')) { - return true; - } - - if (currentSessionIdRef.current === sessionId) { - const explicitSelection = useContextStore.getState().getSessionAgentSelection(sessionId); - if (explicitSelection && explicitSelection !== agentCandidate) { - const status = useSessionStore.getState().sessionStatus?.get(sessionId); - const isBusy = status?.type === 'busy' || status?.type === 'retry'; - if (isBusy) { - return false; - } - } - } - - const last = lastUserAgentSelectionRef.current.get(sessionId); - if (!last) return true; - - if (createdAt === null) { - // If timestamp is missing, never allow it to override a newer selection. - return false; - } - - if (messageId === last.messageId) return true; - return createdAt >= last.created; - })(); - - if (agentCandidate && shouldApplyUserAgentSelection) { - try { - const agents = useConfigStore.getState().agents; - if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentCandidate)) { - const context = useContextStore.getState(); - context.saveSessionAgentSelection(sessionId, agentCandidate); - - lastUserAgentSelectionRef.current.set(sessionId, { - created: createdAt ?? Date.now(), - messageId, - }); - - if (currentSessionIdRef.current === sessionId) { - try { - useConfigStore.getState().setAgent(agentCandidate); - } catch { - // ignored - } - } - - const modelObj = (messageExt as { model?: { providerID?: unknown; modelID?: unknown } }).model; - const providerID = typeof modelObj?.providerID === 'string' ? modelObj.providerID : null; - const modelID = typeof modelObj?.modelID === 'string' ? modelObj.modelID : null; - if (providerID && modelID) { - context.saveSessionModelSelection(sessionId, providerID, modelID); - context.saveAgentModelForSession(sessionId, agentCandidate, providerID, modelID); - const variant = typeof (messageExt as { variant?: unknown }).variant === 'string' - ? (messageExt as { variant: string }).variant - : undefined; - context.saveAgentModelVariantForSession(sessionId, agentCandidate, providerID, modelID, variant); - - if (currentSessionIdRef.current === sessionId) { - try { - useConfigStore.getState().setProvider(providerID); - useConfigStore.getState().setModel(modelID); - } catch { - // ignored - } - } - } - } - } catch { - // ignored - } - } - - if ( - isSyntheticOnly && - (agentCandidate === 'plan' || agentCandidate === 'build') && - currentSessionIdRef.current === sessionId - ) { - const toastKey = `${sessionId}:${messageId}:${agentCandidate}`; - if (!modeSwitchToastShownRef.current.has(toastKey)) { - modeSwitchToastShownRef.current.add(toastKey); - import('sonner').then(({ toast }) => { - toast.info(agentCandidate === 'plan' ? 'Plan mode active' : 'Build mode active', { - description: agentCandidate === 'plan' - ? 'Edits restricted to plan file' - : 'You can now edit files', - duration: 5000, - }); - }); - } - } - - const userMessageInfo = { - ...message, - userMessageMarker: true, - clientRole: 'user', - ...(agentCandidate ? { mode: agentCandidate } : {}), - } as unknown as Message; - - updateMessageInfo(sessionId, messageId, userMessageInfo); - - // Some backends send user message updates without parts. Hydrate from session history. - if (!existingUserMessage && partsArray.length === 0) { - const hydrateKey = `${sessionId}:${messageId}`; - if (!missingMessageHydrationRef.current.has(hydrateKey)) { - missingMessageHydrationRef.current.add(hydrateKey); - void opencodeClient - .getSessionMessages(sessionId) - .then((messages) => { - useSessionStore.getState().syncMessages(sessionId, messages); - }) - .catch(() => { - // ignored - }); - } - } - - if (partsArray.length > 0) { - const directory = readEventDirectory(props); - for (let i = 0; i < partsArray.length; i++) { - const serverPart = partsArray[i]; - const isSynthetic = (serverPart as Record).synthetic === true; - if (isSynthetic) { - const text = (serverPart as { text?: unknown }).text; - const textStr = typeof text === 'string' ? text.trim() : ''; - const shouldKeep = - textStr.startsWith('User has requested to enter plan mode') || - textStr.startsWith('The plan at ') || - textStr.startsWith('The following tool was executed by the user'); - if (!shouldKeep) continue; - } - - const enrichedPart: Part = { - ...serverPart, - type: serverPart?.type || 'text', - sessionID: (serverPart as { sessionID?: string })?.sessionID || sessionId, - messageID: (serverPart as { messageID?: string })?.messageID || messageId, - } as Part; - if (typeof enrichedPart.id === 'string' && typeof enrichedPart.type === 'string') { - writePartTypeHint(`${directory}:${messageId}:${enrichedPart.id}`, enrichedPart.type); - } - addStreamingPart(sessionId, messageId, enrichedPart, 'user'); - } - } - - trackMessage(messageId, 'user_message_created_from_event', { partsCount: partsArray.length }); - break; - } - - const existingMessage = getMessageFromStore(sessionId, messageId); - const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop'; - - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; - const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; - const hasParts = partsArray.length > 0; - const timeObj = (messageExt as { time?: { completed?: number } }).time || {}; - const completedFromServer = typeof timeObj?.completed === 'number'; - const rawStatus = (message as { status?: unknown }).status; - const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : null; - const hasCompletedStatus = status === 'completed' || status === 'complete'; - const finishCandidate = (message as { finish?: unknown }).finish; - const finish = typeof finishCandidate === 'string' ? finishCandidate : null; - const eventHasStopFinish = finish === 'stop'; - const eventHasErrorFinish = finish === 'error'; - - if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish && !eventHasErrorFinish) break; - - const messageInfoOnly = { ...messageExt } as Record; - delete messageInfoOnly.parts; - - updateMessageInfo(sessionId, messageId, messageInfoOnly as unknown as Message); - - const messageRole = typeof (message as { role?: unknown }).role === 'string' - ? (message as { role: string }).role - : null; - const runtimeAPIs = getRegisteredRuntimeAPIs(); - const shouldSynthesizeNotifications = Boolean(runtimeAPIs?.runtime?.isVSCode) && !serverNotificationEventSeenRef.current; - if (shouldSynthesizeNotifications && messageRole === 'assistant') { - const settings = useUIStore.getState(); - const sessionInfo = useSessionStore.getState().sessions.find((entry) => entry.id === sessionId); - const sessionTitle = typeof sessionInfo?.title === 'string' ? sessionInfo.title.trim() : ''; - - if (eventHasStopFinish && settings.notifyOnCompletion !== false) { - const isSubtask = Boolean(sessionInfo?.parentID); - if (!(settings.notifyOnSubtasks === false && isSubtask)) { - const notificationKey = `ready:${sessionId}:${messageId}`; - if (!notifiedMessagesRef.current.has(notificationKey)) { - notifiedMessagesRef.current.add(notificationKey); - dispatchRuntimeNotification({ - title: 'Agent is ready', - body: sessionTitle || 'Task completed', - tag: `ready-${sessionId}`, - }); - } - } - } - - if (eventHasErrorFinish && settings.notifyOnError !== false) { - const notificationKey = `error:${sessionId}:${messageId}`; - if (!notifiedMessagesRef.current.has(notificationKey)) { - notifiedMessagesRef.current.add(notificationKey); - dispatchRuntimeNotification({ - title: 'Tool error', - body: sessionTitle || 'An error occurred', - tag: `error-${sessionId}`, - }); - } - } - } - - const messageTime = (message as { time?: { completed?: unknown } }).time; - const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed; - const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate); - - const stopMarkerPresent = finish === 'stop' || existingStopMarker; - - const shouldFinalizeAssistantMessage = - (message as { role?: string }).role === 'assistant' && - (hasCompletedTimestamp || hasCompletedStatus || stopMarkerPresent); - - if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') { - - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - let latestAssistantMessageId: string | null = null; - let maxId = ''; - - for (let i = 0; i < sessionMessages.length; i++) { - const msg = sessionMessages[i]; - if (msg.info.role === 'assistant' && msg.info.id > maxId) { - maxId = msg.info.id; - latestAssistantMessageId = msg.info.id; - } - } - - const isActiveSession = currentSessionId === sessionId; - if (isActiveSession && messageId !== latestAssistantMessageId) break; - - const timeCompleted = - hasCompletedTimestamp - ? (completedCandidate as number) - : Date.now(); - - if (!hasCompletedTimestamp) { - updateMessageInfo(sessionId, messageId, { - ...message, - time: { ...(messageTime ?? {}), completed: timeCompleted }, - } as unknown as Message); - } - - trackMessage(messageId, 'completed', { timeCompleted }); - reportMessage(messageId); - - void saveSessionCursor(sessionId, messageId, timeCompleted); - - completeStreamingMessage(sessionId, messageId); - repairSessionDerivedState('assistant_message_completed'); - - const rawMessageSessionId = (message as { sessionID?: string }).sessionID; - const messageSessionId: string = - typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 - ? rawMessageSessionId - : sessionId; - requestSessionMetadataRefresh( - messageSessionId, - typeof props.directory === 'string' ? props.directory : null, - ); - - - const summaryInfo = message as Message & { summary?: boolean }; - if (summaryInfo.summary && typeof messageSessionId === 'string') { - updateSessionCompaction(messageSessionId, null); - } - } - break; - } - - case 'session.created': - case 'session.updated': { - const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record : - (typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record : - (typeof props.session === 'object' && props.session !== null) ? props.session as Record : props; - - const sessionId = (typeof candidate.id === 'string' && candidate.id.length > 0) ? candidate.id : - (typeof candidate.sessionID === 'string' && candidate.sessionID.length > 0) ? candidate.sessionID : - (typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID : - (typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined; - - if (sessionId) { - const timeSource = (typeof candidate.time === 'object' && candidate.time !== null) ? candidate.time as Record : - (typeof props.time === 'object' && props.time !== null) ? props.time as Record : null; - const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null; - updateSessionCompaction(sessionId, compactingTimestamp); - - const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string' - ? (candidate as { directory: string }).directory - : typeof props.directory === 'string' - ? (props.directory as string) - : null; - - const patchedSession = { - ...(candidate as unknown as Record), - id: sessionId, - ...(sessionDirectory ? { directory: sessionDirectory } : {}), - } as unknown as Session; - - updateSession(patchedSession); - } - break; - } - - case 'session.deleted': { - const sessionId = typeof props.sessionID === 'string' - ? props.sessionID - : typeof props.id === 'string' - ? props.id - : null; - if (sessionId) { - removeSessionFromStore(sessionId); - } - break; - } - - case 'session.abort': { - const sessionId = - typeof props.sessionID === 'string' && (props.sessionID as string).length > 0 - ? (props.sessionID as string) - : null; - const messageId = - typeof props.messageID === 'string' && (props.messageID as string).length > 0 - ? (props.messageID as string) - : null; - - if (sessionId) { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.abort'); - } - if (sessionId && messageId) { - completeStreamingMessage(sessionId, messageId); - } - break; - } - - case 'permission.asked': { - const request = normalizePermissionRequest(props); - if (!request) { - break; - } - - addPermission(request); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { - const settings = useUIStore.getState(); - if (settings.notifyOnQuestion !== false) { - const notificationKey = `permission:${request.sessionID}:${request.id}`; - if (!notifiedQuestionsRef.current.has(notificationKey)) { - notifiedQuestionsRef.current.add(notificationKey); - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Agent is waiting for your approval'; - dispatchRuntimeNotification({ - title: 'Permission required', - body: sessionTitle, - tag: `permission-${request.sessionID}:${request.id}`, - }); - } - } - } - - // Notify if permission is for another session (common with child sessions). - const toastKey = `${request.sessionID}:${request.id}`; - if (!permissionToastShownRef.current.has(toastKey)) { - setTimeout(() => { - const current = currentSessionIdRef.current; - if (current === request.sessionID) { - return; - } - - const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); - if (requestSession?.parentID && requestSession.parentID === current) { - return; - } - - const pending = useSessionStore - .getState() - .permissions - .get(request.sessionID) - ?.some((entry) => entry.id === request.id); - - if (!pending) { - return; - } - - permissionToastShownRef.current.add(toastKey); - - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Session'; - const permissionBody = buildPermissionToastBody(request); - - import('sonner').then(({ toast }) => { - const isMobile = useUIStore.getState().isMobile; - - if (isMobile) { - toast.warning('Permission required', { - id: toastKey, - description: sessionTitle, - duration: 30000, - action: { - label: 'Open', - onClick: () => { - useUIStore.getState().setActiveMainTab('chat'); - void useSessionStore.getState().setCurrentSession(request.sessionID); - }, - }, - }); - } else { - toast.warning('Permission required', { - id: toastKey, - description: React.createElement(PermissionToastActions, { - sessionTitle, - permissionBody, - onOnce: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'once'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - onAlways: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'always'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - onDeny: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'reject'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - }), - duration: 30000, - }); - } - }); - - }, 0); - } - - break; - } - - case 'permission.replied': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = - typeof props.requestID === 'string' ? props.requestID : - typeof props.id === 'string' ? props.id : null; - if (sessionId && requestId) { - dismissPermission(sessionId, requestId); - } - break; - } - - case 'question.asked': { - if (!('sessionID' in props) || typeof props.sessionID !== 'string') { - break; - } - - const request = props as unknown as QuestionRequest; - addQuestion(request); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { - const settings = useUIStore.getState(); - if (settings.notifyOnQuestion !== false) { - const notificationKey = `question:${request.sessionID}:${request.id}`; - if (!notifiedQuestionsRef.current.has(notificationKey)) { - notifiedQuestionsRef.current.add(notificationKey); - const firstQuestion = Array.isArray(request.questions) ? request.questions[0] : undefined; - const questionHeader = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; - const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; - dispatchRuntimeNotification({ - title: questionHeader || 'Input needed', - body: questionText || 'Agent is waiting for your response', - tag: `question-${request.sessionID}:${request.id}`, - }); - } - } - } - - const toastKey = `${request.sessionID}:${request.id}`; - - // web/desktop use server-emitted notifications; VS Code may synthesize locally - - if (!questionToastShownRef.current.has(toastKey)) { - setTimeout(() => { - const current = currentSessionIdRef.current; - if (current === request.sessionID) { - return; - } - - const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); - if (requestSession?.parentID && requestSession.parentID === current) { - return; - } - - const pending = useSessionStore - .getState() - .questions - .get(request.sessionID) - ?.some((entry) => entry.id === request.id); - - if (!pending) { - return; - } - - questionToastShownRef.current.add(toastKey); - - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Session'; - - import('sonner').then(({ toast }) => { - toast.info('Input needed', { - id: toastKey, - description: sessionTitle, - duration: 30000, - action: { - label: 'Open', - onClick: () => { - useUIStore.getState().setActiveMainTab('chat'); - void useSessionStore.getState().setCurrentSession(request.sessionID); - }, - }, - }); - }); - }, 0); - } - - break; - } - - case 'question.replied': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = typeof props.requestID === 'string' ? props.requestID : null; - if (sessionId && requestId) { - dismissQuestion(sessionId, requestId); - } - break; - } - - case 'question.rejected': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = typeof props.requestID === 'string' ? props.requestID : null; - if (sessionId && requestId) { - dismissQuestion(sessionId, requestId); - } - break; - } - - case 'openchamber:notification': { - serverNotificationEventSeenRef.current = true; - const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : ''; - const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : ''; - const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined; - const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden); - - // When the sidecar stdout notification channel is active (production desktop builds), - // skip this SSE notification to avoid duplicating the native notification already - // shown by the Tauri process. In dev mode the stdout channel is not available, - // so we fall through and let the UI handle it via Tauri IPC. - if (isDesktopLocalOriginActive() && Boolean((props as { desktopStdoutActive?: unknown }).desktopStdoutActive)) { - break; - } - - dispatchRuntimeNotification({ title, body, tag, requireHidden }); - - break; - } - - case 'todo.updated': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const todos = Array.isArray(props.todos) ? props.todos : null; - if (sessionId && todos) { - handleTodoUpdatedEvent( - sessionId, - todos as Array<{ id: string; content: string; status: string; priority: string }> - ); - } - break; - } - } - }, [ - currentSessionId, - addStreamingPart, - applyPartDelta, - completeStreamingMessage, - updateMessageInfo, - addPermission, - dismissPermission, - addQuestion, - dismissQuestion, - checkConnection, - requestSessionMetadataRefresh, - updateSessionCompaction, - applySessionMetadata, - trackMessage, - reportMessage, - requestPendingQuestionsRefresh, - - updateSession, - removeSessionFromStore, - bootstrapState, - effectiveDirectory, - updateSessionStatus, - updateSessionActivityPhase, - repairSessionDerivedState, - dispatchRuntimeNotification, - emitGitRefreshHint, - writePartTypeHint, - ]); - - // --- Stable callback refs (Part A) --- - // Keep refs up to date with the latest version of each callback. - // This lets startStream use stable wrappers with empty deps so SSE connections - // are NOT torn down on every session switch. - const handleEventRef = React.useRef(handleEvent); - React.useEffect(() => { - handleEventRef.current = handleEvent; - }, [handleEvent]); - - const bootstrapStateRef = React.useRef(bootstrapState); - React.useEffect(() => { - bootstrapStateRef.current = bootstrapState; - }, [bootstrapState]); - - // Stable wrappers — identity never changes, so startStream deps stay minimal. - const stableHandleEvent = React.useCallback((event: EventData) => { - handleEventRef.current(event); - }, []); // intentionally empty deps - - const stableBootstrapState = React.useCallback((reason: string) => { - return bootstrapStateRef.current(reason); - }, []); // intentionally empty deps - - const shouldHoldConnection = React.useCallback(() => { - const currentVisibility = resolveVisibilityState(); - visibilityStateRef.current = currentVisibility; - return currentVisibility === 'visible' && onlineStatusRef.current; - }, [resolveVisibilityState]); - - const debugConnectionState = React.useCallback(() => { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Connection state:', { - hasUnsubscribe: Boolean(unsubscribeRef.current), - currentSessionId: currentSessionIdRef.current, - effectiveDirectory, - onlineStatus: onlineStatusRef.current, - visibilityState: visibilityStateRef.current, - lastEventTimestamp: lastEventTimestampRef.current, - reconnectAttempts: reconnectAttemptsRef.current, - }); - } - }, [effectiveDirectory]); - - const stopStream = React.useCallback(() => { - if (isCleaningUpRef.current) { - if (streamDebugEnabled()) { - console.info('[useEventStream] Already cleaning up, skipping stopStream'); - } - return; - } - - isCleaningUpRef.current = true; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - if (unsubscribeRef.current) { - const unsubscribe = unsubscribeRef.current; - unsubscribeRef.current = null; - try { - - unsubscribe(); - } catch (error) { - console.warn('[useEventStream] Error during unsubscribe:', error); - } - } - - isCleaningUpRef.current = false; - }, []); - - const startStream = React.useCallback(async (options?: { resetAttempts?: boolean }) => { - debugConnectionState(); - - if (!shouldHoldConnection()) { - pendingResumeRef.current = true; - if (!onlineStatusRef.current) { - publishStatus('offline', 'Waiting for network'); - } else { - publishStatus('paused', 'Paused while hidden'); - } - return; - } - - if (options?.resetAttempts) { - reconnectAttemptsRef.current = 0; - } - - stopStream(); - lastEventTimestampRef.current = Date.now(); - publishStatus('connecting', null); - - if (streamDebugEnabled()) { - console.info('[useEventStream] Starting event stream...'); - } - - const onError = (error: unknown) => { - console.warn('Event stream error:', error); - - }; - - const onOpen = () => { - const shouldRefresh = pendingResumeRef.current; - reconnectAttemptsRef.current = 0; - pendingResumeRef.current = false; - lastEventTimestampRef.current = Date.now(); - publishStatus('connected', null); - checkConnection(); - repairSessionDerivedState('stream_open'); - - requestPendingPermissionsRefreshRef.current(shouldRefresh); - - if (shouldRefresh) { - void stableBootstrapState('sse_reconnected'); - } else { - const sessionId = currentSessionIdRef.current; - if (sessionId) { - setTimeout(() => { - scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit()) - .then(() => requestSessionMetadataRefresh(sessionId)) - .catch((error: unknown) => { - console.warn('[useEventStream] Failed to resync messages after reconnect:', error); - }); - }, 0); - } - } - }; - - if (streamDebugEnabled()) { - console.info('[useEventStream] Connecting to event source (SDK SSE only):', { - effectiveDirectory, - isCleaningUp: isCleaningUpRef.current, - }); - } - - if (isCleaningUpRef.current) { - if (streamDebugEnabled()) { - console.info('[useEventStream] Skipping subscription due to cleanup in progress'); - } - return; - } - - try { - const sdkUnsub = opencodeClient.subscribeToGlobalEvents( - (event: RoutedOpencodeEvent) => { - const payload = event.payload as unknown as EventData; - const payloadRecord = event.payload as unknown as Record; - const baseProperties = - typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null - ? (payloadRecord.properties as Record) - : {}; - - const properties = - event.directory && event.directory !== 'global' - ? { ...baseProperties, directory: event.directory } - : baseProperties; - - stableHandleEvent({ - type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '', - properties, - }); - }, - onError, - onOpen, - ); - - - const compositeUnsub = () => { - try { - sdkUnsub(); - } catch (cleanupError) { - console.warn('[useEventStream] Error during unsubscribe:', cleanupError); - } - }; - - if (!isCleaningUpRef.current) { - unsubscribeRef.current = compositeUnsub; - } else { - compositeUnsub(); - } - } catch (subscriptionError) { - console.error('[useEventStream] Error during subscription:', subscriptionError); - onError(subscriptionError); - } - }, [ - shouldHoldConnection, - stopStream, - publishStatus, - checkConnection, - requestSessionMetadataRefresh, - stableHandleEvent, - stableBootstrapState, - repairSessionDerivedState, - effectiveDirectory, - debugConnectionState, - ]); - - const scheduleReconnect = React.useCallback((hint?: string) => { - if (!shouldHoldConnection()) { - pendingResumeRef.current = true; - stopStream(); - if (!onlineStatusRef.current) { - publishStatus('offline', 'Waiting for network'); - } else { - publishStatus('paused', 'Paused while hidden'); - } - return; - } - - if (reconnectTimeoutRef.current) { - return; - } - - const nextAttempt = reconnectAttemptsRef.current + 1; - reconnectAttemptsRef.current = nextAttempt; - const statusHint = hint ?? `Retrying (${nextAttempt})`; - publishStatus('reconnecting', statusHint); - - const baseDelay = nextAttempt <= 3 - ? Math.min(1000 * Math.pow(2, nextAttempt - 1), 8000) - : Math.min(2000 * Math.pow(2, nextAttempt - 3), 32000); - const jitter = Math.floor(Math.random() * 250); - const delay = baseDelay + jitter; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - reconnectTimeoutRef.current = setTimeout(() => { - startStream({ resetAttempts: false }); - }, delay); - }, [shouldHoldConnection, stopStream, publishStatus, startStream]); - - React.useEffect(() => { - scheduleReconnectRef.current = scheduleReconnect; - }, [scheduleReconnect]); - - React.useEffect(() => { - if (!enabled) { - stopStream(); - publishStatus('idle', null); - return; - } - - if (typeof window !== 'undefined') { - window.__messageTracker = trackMessage; - } - - // No-op - - const desktopActivityHandler = null; - - const clearPauseTimeout = () => { - if (pauseTimeoutRef.current) { - clearTimeout(pauseTimeoutRef.current); - pauseTimeoutRef.current = null; - } - }; - - const handleVisibilityChange = () => { - visibilityStateRef.current = resolveVisibilityState(); - - if (visibilityStateRef.current !== 'visible') { - // Keep SSE connection alive while hidden; browsers may briefly toggle - // visibility during tab/window transitions. - return; - } - - clearPauseTimeout(); - maybeBootstrapIfStale('visibility_restore'); - repairSessionDerivedState('visibility_restore'); - - const isStalled = Date.now() - lastEventTimestampRef.current > 45000; - if (isStalled) { - console.info('[useEventStream] Visibility restored with stalled stream, reconnecting...'); - pendingResumeRef.current = true; - } - - if (pendingResumeRef.current || !unsubscribeRef.current) { - console.info('[useEventStream] Visibility restored, triggering soft refresh...'); - const sessionId = currentSessionIdRef.current; - if (sessionId) { - scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit()); - requestSessionMetadataRefresh(sessionId); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('visibility_restore_resume'); - publishStatus('connecting', 'Resuming stream'); - startStream({ resetAttempts: true }); - } - }; - - const handleWindowFocus = () => { - visibilityStateRef.current = resolveVisibilityState(); - - if (visibilityStateRef.current === 'visible') { - clearPauseTimeout(); - maybeBootstrapIfStale('window_focus'); - repairSessionDerivedState('window_focus'); - - const isStalled = Date.now() - lastEventTimestampRef.current > 45000; - if (isStalled) { - pendingResumeRef.current = true; - } - - if (pendingResumeRef.current || !unsubscribeRef.current) { - console.info('[useEventStream] Window focused after pause, triggering soft refresh...'); - const sessionId = currentSessionIdRef.current; - if (sessionId) { - requestSessionMetadataRefresh(sessionId); - scheduleSoftResync(sessionId, 'window_focus', getMessageLimit()); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('window_focus_resume'); - - publishStatus('connecting', 'Resuming stream'); - startStream({ resetAttempts: true }); - } - } - }; - - const handleOnline = () => { - onlineStatusRef.current = true; - maybeBootstrapIfStale('network_restored'); - repairSessionDerivedState('network_restored'); - requestPendingPermissionsRefreshRef.current(false); - if (pendingResumeRef.current || !unsubscribeRef.current) { - publishStatus('connecting', 'Network restored'); - startStream({ resetAttempts: true }); - } - }; - - const handleOffline = () => { - onlineStatusRef.current = false; - pendingResumeRef.current = true; - publishStatus('offline', 'Waiting for network'); - stopStream(); - }; - - const handlePageHide = () => { - pendingResumeRef.current = true; - stopStream(); - publishStatus('paused', 'Paused while hidden'); - }; - - const handlePageShow = (event: PageTransitionEvent) => { - // If page was restored from bfcache, SSE is definitely gone. - pendingResumeRef.current = pendingResumeRef.current || Boolean(event.persisted); - visibilityStateRef.current = resolveVisibilityState(); - if (visibilityStateRef.current === 'visible') { - const sessionId = currentSessionIdRef.current; - if (sessionId) { - void scheduleSoftResync(sessionId, 'page_show', getMessageLimit()); - requestSessionMetadataRefresh(sessionId); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('page_show'); - startStream({ resetAttempts: true }); - } - }; - - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', handleVisibilityChange); - } - - if (typeof window !== 'undefined') { - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - window.addEventListener('focus', handleWindowFocus); - window.addEventListener('pagehide', handlePageHide); - window.addEventListener('pageshow', handlePageShow as EventListener); - } - - const startTimer = setTimeout(() => { - startStream({ resetAttempts: true }); - }, 100); - - if (staleCheckIntervalRef.current) { - clearInterval(staleCheckIntervalRef.current); - } - - staleCheckIntervalRef.current = setInterval(() => { - if (!shouldHoldConnection()) return; - - const now = Date.now(); - const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some( - (status) => status?.type === 'busy' || status?.type === 'retry' - ); - - if (hasBusySessions) { - repairSessionDerivedState('stale_check_busy_sessions'); - } - if (now - lastEventTimestampRef.current > 45000) { - Promise.resolve().then(async () => { - try { - const healthy = await opencodeClient.checkHealth(); - if (!healthy) { - scheduleReconnect('Refreshing stalled stream'); - return; - } - - // If health is ok but SSE has been silent (including heartbeat), - // treat it as a stalled connection and reconnect. - scheduleReconnect('Refreshing stalled stream'); - } catch (error) { - console.warn('Health check after stale stream failed:', error); - scheduleReconnect('Refreshing stalled stream'); - } - }); - } - }, 10000); - - // Part B: Idle timeout recovery — scan for sessions stuck in 'busy'/'retry' - // with no recent SSE events and force-reset them to 'idle'. - const stuckCheckInterval = setInterval(() => { - const sessionStatus = useSessionStore.getState().sessionStatus; - if (!sessionStatus) return; - const now = Date.now(); - sessionStatus.forEach((status, sessionId) => { - if (status.type !== 'busy' && status.type !== 'retry') return; - const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0; - const busyTooLong = now - lastMsgAt > STUCK_SESSION_TIMEOUT_MS; - const noRecentEvents = now - lastMsgAt > 60000; - if (busyTooLong && noRecentEvents) { - console.warn('[useEventStream] Session stuck in busy state, forcing idle:', sessionId); - updateSessionStatus(sessionId, { type: 'idle' }, 'timeout_recovery'); - } - }); - }, 30000); // check every 30s - - return () => { - clearTimeout(startTimer); - clearInterval(stuckCheckInterval); - - void desktopActivityHandler; - - if (typeof document !== 'undefined') { - document.removeEventListener('visibilitychange', handleVisibilityChange); - } - - if (typeof window !== 'undefined') { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - window.removeEventListener('focus', handleWindowFocus); - window.removeEventListener('pagehide', handlePageHide); - window.removeEventListener('pageshow', handlePageShow as EventListener); - } - - clearPauseTimeout(); - - if (staleCheckIntervalRef.current) { - clearInterval(staleCheckIntervalRef.current); - staleCheckIntervalRef.current = null; - } - - clearSessionActivityTimers(); - - messageCache.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time - notifiedMessagesRef.current.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time - notifiedQuestionsRef.current.clear(); - serverNotificationEventSeenRef.current = false; - - pendingResumeRef.current = false; - visibilityStateRef.current = resolveVisibilityState(); - onlineStatusRef.current = typeof navigator === 'undefined' ? true : navigator.onLine; - - stopStream(); - - if (sessionRefreshTimeoutRef.current) { - clearTimeout(sessionRefreshTimeoutRef.current); - sessionRefreshTimeoutRef.current = null; - } - - publishStatus('idle', null); - }; - }, [ - enabled, - effectiveDirectory, - trackMessage, - resolveVisibilityState, - stopStream, - publishStatus, - startStream, - scheduleReconnect, - loadMessages, - requestSessionMetadataRefresh, - refreshSessionActivityStatus, - clearSessionActivityTimers, - repairSessionDerivedState, - - - shouldHoldConnection, - loadSessions, - maybeBootstrapIfStale, - resyncMessages, - scheduleSoftResync, - updateSessionStatus, - ]); -}; diff --git a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts index 1b2fb61e..94056273 100644 --- a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts +++ b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts @@ -1,11 +1,13 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import type { RuntimeAPIs } from '@/lib/api/types'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; const MAX_BACKGROUND_PR_DIRECTORIES = 20; const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000; @@ -94,34 +96,6 @@ const prioritizeDirectoriesForFetch = ( }); }; -const mapWithConcurrency = async ( - values: T[], - concurrency: number, - mapper: (value: T) => Promise, -): Promise => { - if (values.length === 0) { - return []; - } - - const safeConcurrency = Math.max(1, Math.min(concurrency, values.length)); - const results = new Array(values.length); - let cursor = 0; - - const worker = async () => { - while (true) { - const nextIndex = cursor; - cursor += 1; - if (nextIndex >= values.length) { - return; - } - results[nextIndex] = await mapper(values[nextIndex]); - } - }; - - await Promise.all(Array.from({ length: safeConcurrency }, () => worker())); - return results; -}; - const toPrTargets = (cache: Map, directories: string[]): PrTarget[] => { const result: PrTarget[] = []; directories.forEach((directory) => { @@ -144,10 +118,9 @@ export const useGitHubPrBackgroundTracking = ( ): void => { const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projects = useProjectsStore((state) => state.projects); - const sessions = useSessionStore((state) => state.sessions); - const archivedSessions = useSessionStore((state) => state.archivedSessions); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); + const sessions = useSessions(); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -229,7 +202,7 @@ export const useGitHubPrBackgroundTracking = ( add(metadata.path); }); - [...sessions, ...archivedSessions] + [...sessions] .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) .forEach((rawSession) => { const session = rawSession as SessionLike; @@ -238,7 +211,7 @@ export const useGitHubPrBackgroundTracking = ( }); return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES); - }, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); + }, [availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); React.useEffect(() => { let cancelled = false; @@ -275,7 +248,7 @@ export const useGitHubPrBackgroundTracking = ( STATUS_FETCH_CONCURRENCY, async (directory) => { try { - const status = await git.getGitStatus(directory); + const status = await git.getGitStatus(directory, { mode: 'light' }); const branch = typeof status.current === 'string' ? status.current.trim() : ''; return { directory, @@ -383,7 +356,11 @@ export const useGitHubPrBackgroundTracking = ( } }; - void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }); + // Delay initial PR tracking to avoid startup CPU burst + const startupDelayId = window.setTimeout(() => { + if (cancelled) return; + void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }); + }, 5_000); const intervalId = window.setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { @@ -442,6 +419,7 @@ export const useGitHubPrBackgroundTracking = ( return () => { cancelled = true; + window.clearTimeout(startupDelayId); window.clearInterval(intervalId); window.removeEventListener('focus', refreshOnResume); document.removeEventListener('visibilitychange', refreshOnResume); diff --git a/packages/ui/src/hooks/useGitPollingHook.ts b/packages/ui/src/hooks/useGitPollingHook.ts index c38ffd2c..004e8df5 100644 --- a/packages/ui/src/hooks/useGitPollingHook.ts +++ b/packages/ui/src/hooks/useGitPollingHook.ts @@ -2,7 +2,8 @@ import React from 'react'; import { useGitStore } from '@/stores/useGitStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionStatus } from '@/sync/sync-context'; /** * Background git polling hook - monitors git status regardless of which tab is open. @@ -20,8 +21,17 @@ export function useGitPolling() { const { git } = useRuntimeAPIs(); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); - const { currentSessionId, sessions, worktreeMetadata: worktreeMap, sessionStatus } = useSessionStore(); - const { setActiveDirectory, startPolling, setPollingMode, stopPolling, fetchAll, fetchStatus, clearDiffCache } = useGitStore(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); + const currentStatus = useSessionStatus(currentSessionId ?? ''); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const startPolling = useGitStore((state) => state.startPolling); + const setPollingMode = useGitStore((state) => state.setPollingMode); + const stopPolling = useGitStore((state) => state.stopPolling); + const fetchAll = useGitStore((state) => state.fetchAll); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const clearDiffCache = useGitStore((state) => state.clearDiffCache); const immediateRefreshTimerRef = React.useRef | null>(null); const lastImmediateRefreshAtRef = React.useRef(0); @@ -40,12 +50,12 @@ export function useGitPolling() { if (!currentSessionId) { return 'idle'; } - const activeStatus = sessionStatus?.get(currentSessionId)?.type; + const activeStatus = currentStatus?.type; if (activeStatus === 'busy' || activeStatus === 'retry') { return activeStatus; } return 'idle'; - }, [currentSessionId, sessionStatus]); + }, [currentSessionId, currentStatus]); const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal'; @@ -84,7 +94,7 @@ export function useGitPolling() { immediateRefreshTimerRef.current = null; lastImmediateRefreshAtRef.current = Date.now(); void (async () => { - const statusChanged = await fetchStatus(targetDirectory, git, { silent: true }); + const statusChanged = await fetchStatus(targetDirectory, git, { silent: true, mode: 'light' }); if (shouldForceDiffRefresh && !statusChanged) { clearDiffCache(targetDirectory); } diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 43ee4671..88ff4735 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,7 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; @@ -10,24 +12,26 @@ import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; export const useKeyboardShortcuts = () => { - const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); - const { - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - toggleRightSidebar, - setRightSidebarOpen, - setRightSidebarTab, - toggleBottomTerminal, - setBottomTerminalExpanded, - isMobile, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setModelSelectorOpen, - toggleExpandedInput, - shortcutOverrides, - } = useUIStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt); + const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const abortCurrentOperation = sessionActions.abortCurrentOperation;; + const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); + const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen); + const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab); + const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal); + const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded); + const isMobile = useUIStore((s) => s.isMobile); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen); + const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput); + const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); const { themeMode, setThemeMode } = useThemeSystem(); const { working } = useAssistantStatus(); const abortPrimedUntilRef = React.useRef(null); @@ -108,13 +112,6 @@ export const useKeyboardShortcuts = () => { return; } - if (eventMatchesShortcut(e, combo('open_timeline'))) { - e.preventDefault(); - const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState(); - setTimelineDialogOpen(!isTimelineDialogOpen); - return; - } - if (eventMatchesShortcut(e, combo('open_settings'))) { e.preventDefault(); const { isSettingsDialogOpen } = useUIStore.getState(); @@ -270,14 +267,13 @@ export const useKeyboardShortcuts = () => { configState.cycleCurrentVariant(); const nextVariant = useConfigStore.getState().currentVariant; - const sessionState = useSessionStore.getState(); - const sessionId = sessionState.currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; const agentName = useConfigStore.getState().currentAgentName; const providerId = useConfigStore.getState().currentProviderId; const modelId = useConfigStore.getState().currentModelId; if (sessionId && agentName && providerId && modelId) { - sessionState.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); + useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); } return; @@ -387,7 +383,7 @@ export const useKeyboardShortcuts = () => { if (primedUntil && now < primedUntil) { e.preventDefault(); resetAbortPriming(); - void abortCurrentOperation(sessionId || undefined); + void abortCurrentOperation(sessionId ?? ''); return; } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index d71ac08e..1fed8542 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -1,6 +1,6 @@ import React from 'react'; import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; @@ -48,16 +48,14 @@ type MenuAction = export const useMenuActions = ( onToggleMemoryDebug?: () => void ) => { - const { openNewSessionDraft } = useSessionStore(); - const { - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setAboutDialogOpen, - } = useUIStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); const { addProject } = useProjectsStore(); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); const { requestAccess, startAccessing } = useFileSystemAccess(); diff --git a/packages/ui/src/hooks/usePwaManifestSync.ts b/packages/ui/src/hooks/usePwaManifestSync.ts index d2770dbc..4d3ce7b5 100644 --- a/packages/ui/src/hooks/usePwaManifestSync.ts +++ b/packages/ui/src/hooks/usePwaManifestSync.ts @@ -1,5 +1,6 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { isWebRuntime } from '@/lib/desktop'; import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa'; @@ -60,8 +61,8 @@ const buildRecentShortcuts = ( }; export const usePwaManifestSync = () => { - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const recentShortcuts = React.useMemo(() => { return buildRecentShortcuts(sessions, currentSessionId); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 044ae412..df3241ac 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -1,32 +1,26 @@ import React from 'react'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; +import { getSyncSessionStatus } from '@/sync/sync-refs'; +import { useDirectorySync } from '@/sync/sync-context'; type SessionStatusType = 'idle' | 'busy' | 'retry'; const RECENT_ABORT_WINDOW_MS = 2000; const hasRecentAbort = (sessionId: string): boolean => { - const abortRecord = useSessionStore.getState().sessionAbortFlags.get(sessionId); + const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId); if (!abortRecord) { return false; } return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; }; -const setSessionStatus = (sessionId: string, type: SessionStatusType) => { - useSessionStore.setState((state) => { - const next = new Map(state.sessionStatus ?? new Map()); - next.set(sessionId, { type }); - return { sessionStatus: next }; - }); -}; - const buildQueuedPayload = (queue: QueuedMessage[]) => { const agents = useConfigStore.getState().getVisibleAgents(); let primaryText = ''; @@ -64,7 +58,7 @@ const buildQueuedPayload = (queue: QueuedMessage[]) => { const resolveSessionSendConfig = (sessionId: string) => { const context = useContextStore.getState(); const config = useConfigStore.getState(); - const message = useMessageStore.getState(); + const selection = useSelectionStore.getState(); const selectedAgent = context.getSessionAgentSelection(sessionId) @@ -81,16 +75,17 @@ const resolveSessionSendConfig = (sessionId: string) => { agentModel?.providerId ?? sessionModel?.providerId ?? config.currentProviderId - ?? message.lastUsedProvider?.providerID; + ?? selection.lastUsedProvider?.providerID; const modelID = agentModel?.modelId ?? sessionModel?.modelId ?? config.currentModelId - ?? message.lastUsedProvider?.modelID; + ?? selection.lastUsedProvider?.modelID; const variant = selectedAgent && providerID && modelID - ? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) + ? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) + ?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)) : undefined; return { @@ -101,10 +96,10 @@ const resolveSessionSendConfig = (sessionId: string) => { }; }; -export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { - const enabled = options?.enabled ?? true; +export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) { + const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true); const queuedMessages = useMessageQueueStore((state) => state.queuedMessages); - const sessionStatus = useSessionStore((state) => state.sessionStatus); + const sessionStatusRecord = useDirectorySync((state) => state.session_status); const inFlightSessionsRef = React.useRef>(new Set()); const previousStatusRef = React.useRef>(new Map()); @@ -125,7 +120,7 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { return; } - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId)?.type ?? 'idle'; + const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle'; if (currentStatus !== 'idle') { return; } @@ -135,21 +130,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { return; } - const resolved = resolveSessionSendConfig(sessionId); + // Use send config captured at queue time; fall back to current config + const captured = queueSnapshot[0]?.sendConfig; + const resolved = captured?.providerID && captured?.modelID + ? captured + : resolveSessionSendConfig(sessionId); if (!resolved.providerID || !resolved.modelID) { return; } inFlightSessionsRef.current.add(sessionId); - setSessionStatus(sessionId, 'busy'); try { - await useMessageStore.getState().sendMessage( + await useSessionUIStore.getState().sendMessage( payload.primaryText, resolved.providerID, resolved.modelID, resolved.agent, - sessionId, payload.primaryAttachments, payload.agentMentionName, payload.additionalParts, @@ -162,22 +159,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { removeFromQueue(sessionId, item.id); }); } catch (error) { - setSessionStatus(sessionId, 'idle'); console.warn('[queue] queued auto-send failed:', error); } finally { inFlightSessionsRef.current.delete(sessionId); } }; + const statusRecord = sessionStatusRecord ?? {}; const nextStatusMap = new Map(previousStatusRef.current); - const statusEntries = sessionStatus ? Array.from(sessionStatus.entries()) : []; - statusEntries.forEach(([sessionId, status]) => { - nextStatusMap.set(sessionId, status.type); - }); + for (const [sessionId, status] of Object.entries(statusRecord)) { + if (status) { + nextStatusMap.set(sessionId, status.type as SessionStatusType); + } + } const queueEntries = Object.entries(queuedMessages); queueEntries.forEach(([sessionId, queue]) => { - const currentStatusType = (sessionStatus?.get(sessionId)?.type ?? 'idle') as SessionStatusType; + const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType; const previousStatusType = previousStatusRef.current.get(sessionId); const becameIdle = (previousStatusType === 'busy' || previousStatusType === 'retry') @@ -192,5 +190,5 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { }); previousStatusRef.current = nextStatusMap; - }, [enabled, queuedMessages, sessionStatus]); + }, [enabled, queuedMessages, sessionStatusRecord]); } diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index b706dd19..e49f905e 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; import type { RouteState, AppRouteState } from '@/lib/router'; @@ -38,7 +38,7 @@ export function useRouter(): void { const isApplyingRouteRef = React.useRef(false); // Get store actions (stable references) - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); @@ -58,7 +58,7 @@ export function useRouter(): void { try { // 1. Apply session first (may trigger async operations) if (route.sessionId) { - const currentSessionId = useSessionStore.getState().currentSessionId; + const currentSessionId = useSessionUIStore.getState().currentSessionId; if (route.sessionId !== currentSessionId) { await setCurrentSession(route.sessionId); } @@ -97,7 +97,7 @@ export function useRouter(): void { * Get current app state for URL serialization. */ const getCurrentAppState = React.useCallback((): AppRouteState => { - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const uiState = useUIStore.getState(); return { @@ -158,9 +158,9 @@ export function useRouter(): void { return; } - let prevSessionId: string | null = useSessionStore.getState().currentSessionId; + let prevSessionId: string | null = useSessionUIStore.getState().currentSessionId; - const unsubscribe = useSessionStore.subscribe((state) => { + const unsubscribe = useSessionUIStore.subscribe((state) => { const sessionId = state.currentSessionId; // Skip if no change or if we're currently applying a route @@ -261,7 +261,7 @@ export function navigateToRoute(route: Partial): void { if (win.__VSCODE_CONFIG__ !== undefined) { // In VS Code, just apply state changes directly if (route.sessionId) { - void useSessionStore.getState().setCurrentSession(route.sessionId); + void useSessionUIStore.getState().setCurrentSession(route.sessionId); } if (route.settingsPath) { useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); @@ -300,7 +300,7 @@ export function navigateToRoute(route: Partial): void { // Also apply to state if (route.sessionId) { - void useSessionStore.getState().setCurrentSession(route.sessionId); + void useSessionUIStore.getState().setCurrentSession(route.sessionId); } if (route.settingsPath) { useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); @@ -321,7 +321,7 @@ export function getShareableURL(): string { return '/'; } - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const uiState = useUIStore.getState(); const params = new URLSearchParams(); diff --git a/packages/ui/src/hooks/useServerSessionStatus.ts b/packages/ui/src/hooks/useServerSessionStatus.ts deleted file mode 100644 index 794a0a02..00000000 --- a/packages/ui/src/hooks/useServerSessionStatus.ts +++ /dev/null @@ -1,334 +0,0 @@ -import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { opencodeClient } from '@/lib/opencode/client'; - -interface SessionState { - status: 'idle' | 'busy' | 'retry'; - lastUpdateAt: number; - metadata?: { - attempt?: number; - message?: string; - next?: number; - }; -} - -interface SessionAttentionState { - needsAttention: boolean; - lastUserMessageAt: number | null; - lastStatusChangeAt: number; - status: 'idle' | 'busy' | 'retry'; - isViewed: boolean; -} - -interface ServerSnapshotResponse { - statusSessions: Record; - attentionSessions: Record; - serverTime: number; -} - -const IMMEDIATE_POLL_DELAY_MS = 150; -const FOLLOW_UP_POLL_DELAY_MS = 1100; -const MIN_IMMEDIATE_POLL_GAP_MS = 1200; -const FOLLOW_UP_REARM_COOLDOWN_MS = 5000; - -// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll -let triggerImmediatePollRef: (() => void) | null = null; - -// Global function to trigger immediate poll from outside React -export const triggerSessionStatusPoll = () => { - if (triggerImmediatePollRef) { - triggerImmediatePollRef(); - } -}; - -/** - * Hook to synchronize session status and attention state from server. - * - * Architecture: server maintains authoritative state, client applies snapshots. - * SSE remains the primary transport; snapshots repair missed updates. - */ -export function useServerSessionStatus(options?: { enabled?: boolean }) { - const enabled = options?.enabled ?? true; - const isSyncingRef = React.useRef(false); - const hasPendingImmediateSyncRef = React.useRef(false); - const lastSyncAtRef = React.useRef(0); - const lastImmediatePollRequestAtRef = React.useRef(0); - const lastFollowUpPollRequestAtRef = React.useRef(0); - const timeoutRef = React.useRef(null); - const followUpTimeoutRef = React.useRef(null); - - const fetchSessionStatus = React.useCallback(async (immediate = false) => { - const now = Date.now(); - if (!immediate && now - lastSyncAtRef.current < 1000) { - return; - } - if (immediate && now - lastSyncAtRef.current < 600) { - return; - } - - // Prevent concurrent syncs; if an immediate sync is requested while running, - // queue one more pass right after current request settles. - if (isSyncingRef.current) { - if (immediate) { - hasPendingImmediateSyncRef.current = true; - } - return; - } - - isSyncingRef.current = true; - lastSyncAtRef.current = now; - - try { - const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([ - fetch('/api/sessions/snapshot', { - method: 'GET', - cache: 'no-store', - headers: { Accept: 'application/json' }, - }).then(async (r) => { - if (!r.ok) { - console.warn('[useServerSessionStatus] API returned', r.status); - if (r.status === 401) { - console.warn('[useServerSessionStatus] Authentication required - session may have expired'); - } - throw new Error(String(r.status)); - } - return (await r.json()) as ServerSnapshotResponse; - }), - opencodeClient.getGlobalSessionStatus(), - ]); - - const snapshotData: ServerSnapshotResponse | null = - snapshotResult.status === 'fulfilled' ? snapshotResult.value : null; - const statusSessions = snapshotData?.statusSessions ?? {}; - const attentionSessions = snapshotData?.attentionSessions ?? {}; - - const upstreamStatuses = - upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {}; - - // Update the session store with server state - const currentStatuses = useSessionStore.getState().sessionStatus || new Map(); - let newStatuses: Map | null = null; - const ensureStatusesMap = () => { - if (!newStatuses) { - newStatuses = new Map(currentStatuses); - } - return newStatuses; - }; - - for (const [sessionId, state] of Object.entries(statusSessions)) { - const existing = currentStatuses.get(sessionId); - const hasChanged = - !existing || - existing.type !== state.status || - existing.attempt !== state.metadata?.attempt || - existing.message !== state.metadata?.message || - existing.next !== state.metadata?.next || - existing.confirmedAt !== state.lastUpdateAt; - - // Only update if server state is different - if (hasChanged) { - ensureStatusesMap().set(sessionId, { - type: state.status, - confirmedAt: state.lastUpdateAt, - attempt: state.metadata?.attempt, - message: state.metadata?.message, - next: state.metadata?.next, - }); - } - } - - // Overlay OpenCode's own session status endpoint. - // This is the source-of-truth for retry message payload and works even when - // OpenChamber server-side tracking misses transient updates. - for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) { - const existing = (newStatuses ?? currentStatuses).get(sessionId); - const hasChanged = - !existing || - existing.type !== upstream.type || - existing.attempt !== upstream.attempt || - existing.message !== upstream.message || - existing.next !== upstream.next; - - if (hasChanged) { - ensureStatusesMap().set(sessionId, { - type: upstream.type, - confirmedAt: Date.now(), - attempt: upstream.attempt, - message: upstream.message, - next: upstream.next, - }); - } - } - - // Check for sessions that are no longer in server state (treat as idle) - const activeServerStatusIds = new Set(Object.keys(statusSessions)); - const activeUpstreamIds = new Set(Object.keys(upstreamStatuses)); - - for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) { - if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') && - !activeServerStatusIds.has(sessionId) && - !activeUpstreamIds.has(sessionId)) { - // Session was busy but not in server state anymore -> mark as idle - ensureStatusesMap().set(sessionId, { - type: 'idle', - confirmedAt: Date.now(), - }); - } - } - - // Update attention state from server - const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); - let newAttentionStates: Map | null = null; - const ensureAttentionMap = () => { - if (!newAttentionStates) { - newAttentionStates = new Map(currentAttentionStates); - } - return newAttentionStates; - }; - let attentionStatesChanged = false; - - for (const [sessionId, attentionState] of Object.entries(attentionSessions)) { - const existing = currentAttentionStates.get(sessionId); - const serverState = attentionState as SessionAttentionState; - const hasChanged = - !existing || - existing.needsAttention !== serverState.needsAttention || - existing.lastUserMessageAt !== serverState.lastUserMessageAt || - existing.lastStatusChangeAt !== serverState.lastStatusChangeAt || - existing.status !== serverState.status || - existing.isViewed !== serverState.isViewed; - - if (hasChanged) { - ensureAttentionMap().set(sessionId, serverState); - attentionStatesChanged = true; - } - } - - // Remove attention states for sessions that no longer exist - for (const sessionId of (newAttentionStates ?? currentAttentionStates).keys()) { - const inStatus = !!statusSessions[sessionId]; - const inAttention = !!attentionSessions[sessionId]; - if (!inStatus && !inAttention) { - ensureAttentionMap().delete(sessionId); - attentionStatesChanged = true; - } - } - - // Only update store if something actually changed - const statusChanged = newStatuses !== null; - if (statusChanged || attentionStatesChanged) { - useSessionStore.setState({ - ...(statusChanged && newStatuses ? { sessionStatus: newStatuses } : {}), - ...(attentionStatesChanged && newAttentionStates ? { sessionAttentionStates: newAttentionStates } : {}), - }); - } - - if (process.env.NODE_ENV === 'development') { - console.debug('[useServerSessionStatus] Updated session statuses from server:', { - statusCount: Object.keys(statusSessions).length, - upstreamCount: Object.keys(upstreamStatuses).length, - attentionCount: Object.keys(attentionSessions).length, - serverTime: snapshotData?.serverTime, - }); - } - } catch (error) { - console.warn('[useServerSessionStatus] Error fetching session status:', error); - } finally { - isSyncingRef.current = false; - if (hasPendingImmediateSyncRef.current) { - hasPendingImmediateSyncRef.current = false; - setTimeout(() => { - void fetchSessionStatus(true); - }, 120); - } - } - }, []); - - // Function to trigger immediate snapshot sync from external modules - const triggerImmediatePoll = React.useCallback(() => { - const now = Date.now(); - const elapsed = now - lastImmediatePollRequestAtRef.current; - lastImmediatePollRequestAtRef.current = now; - - if (!timeoutRef.current) { - const minGapDelay = elapsed >= MIN_IMMEDIATE_POLL_GAP_MS - ? IMMEDIATE_POLL_DELAY_MS - : Math.max(IMMEDIATE_POLL_DELAY_MS, MIN_IMMEDIATE_POLL_GAP_MS - elapsed); - - timeoutRef.current = setTimeout(() => { - timeoutRef.current = null; - void fetchSessionStatus(true); - }, minGapDelay); - } - - // Run one follow-up sync after short settle period to catch delayed - // server status transitions that happen right after reconnect/restore. - // Re-arm at most once per cooldown window to avoid stacked follow-ups. - if (!followUpTimeoutRef.current && now - lastFollowUpPollRequestAtRef.current >= FOLLOW_UP_REARM_COOLDOWN_MS) { - lastFollowUpPollRequestAtRef.current = now; - followUpTimeoutRef.current = setTimeout(() => { - followUpTimeoutRef.current = null; - void fetchSessionStatus(true); - }, FOLLOW_UP_POLL_DELAY_MS); - } - }, [fetchSessionStatus]); - - // Initial snapshot sync on mount - React.useEffect(() => { - if (!enabled) { - return; - } - - void fetchSessionStatus(true); - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (followUpTimeoutRef.current) { - clearTimeout(followUpTimeoutRef.current); - } - }; - }, [enabled, fetchSessionStatus]); - - // Sync snapshot when tab becomes visible - React.useEffect(() => { - if (!enabled) { - return; - } - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - triggerImmediatePoll(); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [enabled, triggerImmediatePoll]); - - // Update the ref for external access - React.useEffect(() => { - if (!enabled) { - triggerImmediatePollRef = null; - return; - } - - triggerImmediatePollRef = triggerImmediatePoll; - return () => { - triggerImmediatePollRef = null; - }; - }, [enabled, triggerImmediatePoll]); - - return { - fetchSessionStatus, - triggerImmediatePoll, - }; -} - -// Export ref accessor for external modules -export const getTriggerImmediatePoll = () => triggerImmediatePollRef; - -export default useServerSessionStatus; diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a3668bfc..a8c4da36 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -1,20 +1,14 @@ - - import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionStatus, useSessionMessages, useSessionPermissions } from '@/sync/sync-context'; // Mirrors OpenCode SessionStatus: busy|retry|idle. export type SessionActivityPhase = 'idle' | 'busy' | 'retry'; export interface SessionActivityResult { - phase: SessionActivityPhase; - isWorking: boolean; - isBusy: boolean; - - // Kept for backward compatibility; always false with server session.status. isCooldown: boolean; } @@ -25,33 +19,49 @@ const IDLE_RESULT: SessionActivityResult = { isCooldown: false, }; +/** + * Determines if a session is actively working. + * Checks session_status and, as a narrow fallback, only the trailing + * assistant message when its completion update has not landed yet. + * Returns idle when permissions are pending (permission indicator takes priority). + */ export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult { - - const phase = useSessionStore((state) => { - if (!sessionId || !state.sessionStatus) { - return 'idle' as SessionActivityPhase; - } - const status = state.sessionStatus.get(sessionId); - return (status?.type ?? 'idle') as SessionActivityPhase; - }); + const status = useSessionStatus(sessionId ?? ''); + const messages = useSessionMessages(sessionId ?? ''); + const permissions = useSessionPermissions(sessionId ?? ''); return React.useMemo(() => { - if (phase === 'idle') { - return IDLE_RESULT; - } - const isBusy = phase === 'busy'; - // No cooldown in server session.status; treat retry as working. - const isCooldown = false; + if (!sessionId) return IDLE_RESULT; + + // Permissions pending → idle (permission indicator takes priority) + if (permissions.length > 0) return IDLE_RESULT; + + const phase: SessionActivityPhase = (status?.type ?? 'idle') as SessionActivityPhase; + + // Only trust the trailing assistant message as a transient fallback while + // waiting for session.status/message.updated to settle. + const lastMessage = messages[messages.length - 1]; + const hasPendingAssistant = Boolean( + lastMessage + && lastMessage.role === 'assistant' + && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number', + ); + + const statusWorking = phase !== 'idle'; + const isWorking = statusWorking || hasPendingAssistant; + + if (!isWorking) return IDLE_RESULT; + return { - phase, - isWorking: phase === 'busy' || phase === 'retry', - isBusy, - isCooldown, + phase: statusWorking ? phase : 'busy', + isWorking: true, + isBusy: phase === 'busy' || (!statusWorking && hasPendingAssistant), + isCooldown: false, }; - }, [phase]); + }, [sessionId, status, messages, permissions]); } export function useCurrentSessionActivity(): SessionActivityResult { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return useSessionActivity(currentSessionId); } diff --git a/packages/ui/src/hooks/useSessionAutoCleanup.ts b/packages/ui/src/hooks/useSessionAutoCleanup.ts index 966989fb..ba03e440 100644 --- a/packages/ui/src/hooks/useSessionAutoCleanup.ts +++ b/packages/ui/src/hooks/useSessionAutoCleanup.ts @@ -1,6 +1,9 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { opencodeClient } from '@/lib/opencode/client'; +import { ensureGlobalSessionsLoaded, useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getAllSyncSessions } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -50,8 +53,9 @@ export const buildAutoDeleteCandidates = ({ }; type CleanupResult = { - deletedIds: string[]; + completedIds: string[]; failedIds: string[]; + action: 'archive' | 'delete'; skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running'; }; @@ -60,57 +64,65 @@ type CleanupOptions = { enabled?: boolean; }; -export const useSessionAutoCleanup = (options?: CleanupOptions) => { +export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOptions) => { + const options = typeof enabledOrOptions === 'object' ? enabledOrOptions : undefined; const autoRun = options?.autoRun !== false; - const enabled = options?.enabled ?? true; + const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (options?.enabled ?? true); - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const isLoading = useSessionStore((state) => state.isLoading); - const deleteSessions = useSessionStore((state) => state.deleteSessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isLoading = useSessionUIStore((state) => state.isLoading); + const globalSessions = useGlobalSessionsStore((state) => state.activeSessions); + const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction); const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt); const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt); const [isRunning, setIsRunning] = React.useState(false); const runningRef = React.useRef(false); + React.useEffect(() => { + void ensureGlobalSessionsLoaded(getAllSyncSessions()); + }, []); + const candidates = React.useMemo(() => { if (autoDeleteAfterDays <= 0) { return []; } return buildAutoDeleteCandidates({ - sessions, + sessions: globalSessions, currentSessionId, cutoffDays: autoDeleteAfterDays, }); - }, [autoDeleteAfterDays, currentSessionId, sessions]); + }, [autoDeleteAfterDays, currentSessionId, globalSessions]); const runCleanup = React.useCallback( - async ({ force = false }: { force?: boolean } = {}): Promise => { + async ({ force = false }: { force?: boolean } = {}): Promise => { if (runningRef.current) { - return { deletedIds: [], failedIds: [], skippedReason: 'running' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'running' }; } if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { if (!force) { - return { deletedIds: [], failedIds: [], skippedReason: 'disabled' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'disabled' }; } } if (isLoading) { - return { deletedIds: [], failedIds: [], skippedReason: 'loading' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'loading' }; } const now = Date.now(); if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) { - return { deletedIds: [], failedIds: [], skippedReason: 'cooldown' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'cooldown' }; } + const { activeSessions: sessions } = await ensureGlobalSessionsLoaded(getAllSyncSessions()); + if (sessions.length === 0) { - return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' }; } const candidateIds = buildAutoDeleteCandidates({ @@ -122,14 +134,44 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { if (candidateIds.length === 0) { setAutoDeleteLastRunAt(now); - return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' }; } runningRef.current = true; setIsRunning(true); try { - const result = await deleteSessions(candidateIds, { silent: true }); - return result; + const sessionMap = new Map(sessions.map((session) => [session.id, session])); + const completedIds: string[] = []; + const failedIds: string[] = []; + + for (const id of candidateIds) { + const session = sessionMap.get(id); + const directory = session ? resolveGlobalSessionDirectory(session) : null; + if (!directory) { + failedIds.push(id); + continue; + } + + const scopedSdk = opencodeClient.getScopedSdkClient(directory); + + try { + if (sessionRetentionAction === 'archive') { + await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } }); + } else { + await scopedSdk.session.delete({ sessionID: id, directory }); + } + completedIds.push(id); + } catch { + failedIds.push(id); + } + } + + if (sessionRetentionAction === 'archive') { + useGlobalSessionsStore.getState().archiveSessions(completedIds); + } else { + useGlobalSessionsStore.getState().removeSessions(completedIds); + } + return { completedIds, failedIds, action: sessionRetentionAction }; } finally { runningRef.current = false; setIsRunning(false); @@ -141,9 +183,8 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { autoDeleteEnabled, autoDeleteLastRunAt, currentSessionId, - deleteSessions, isLoading, - sessions, + sessionRetentionAction, setAutoDeleteLastRunAt, ] ); @@ -159,7 +200,7 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { return; } - if (isLoading || sessions.length === 0) { + if (isLoading || !hasLoadedGlobalSessions || globalSessions.length === 0) { return; } const now = Date.now(); @@ -173,8 +214,9 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { autoDeleteLastRunAt, autoRun, enabled, + hasLoadedGlobalSessions, + globalSessions.length, isLoading, - sessions.length, runCleanup, ]); @@ -183,5 +225,6 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { isRunning, runCleanup, keepRecentCount: AUTO_DELETE_KEEP_RECENT, + action: sessionRetentionAction, }; }; diff --git a/packages/ui/src/hooks/useSessionStatusBootstrap.ts b/packages/ui/src/hooks/useSessionStatusBootstrap.ts index d52399f7..43a69a20 100644 --- a/packages/ui/src/hooks/useSessionStatusBootstrap.ts +++ b/packages/ui/src/hooks/useSessionStatusBootstrap.ts @@ -1,47 +1,8 @@ -import React from 'react'; -import { opencodeClient } from '@/lib/opencode/client'; -import { useSessionStore } from '@/stores/useSessionStore'; - -type SessionStatusPayload = { - type: 'idle' | 'busy' | 'retry'; - attempt?: number; - message?: string; - next?: number; -}; - -export const useSessionStatusBootstrap = (options?: { enabled?: boolean }) => { - const enabled = options?.enabled ?? true; - React.useEffect(() => { - if (!enabled) { - return; - } - - let cancelled = false; - - const bootstrap = async () => { - try { - // Use global status to detect busy sessions across all directories, - // including sessions started externally (e.g., via CLI) before UI opened - const statusMap = await opencodeClient.getGlobalSessionStatus(); - if (cancelled || !statusMap) return; - - const nextStatus = new Map(); - Object.entries(statusMap).forEach(([sessionId, raw]) => { - if (!sessionId || !raw) return; - const status = raw as SessionStatusPayload; - nextStatus.set(sessionId, status); - }); - - if (nextStatus.size > 0) { - useSessionStore.setState({ sessionStatus: nextStatus }); - } - } catch { /* ignored */ } - }; - - void bootstrap(); - - return () => { - cancelled = true; - }; - }, [enabled]); +/** + * Session status bootstrap is now handled by the sync system's own bootstrap + * (sync/bootstrap.ts). This hook is retained as a no-op for call-site compat. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useSessionStatusBootstrap = (_options?: { enabled?: boolean }) => { + // no-op — session_status is bootstrapped by sync child stores }; diff --git a/packages/ui/src/hooks/useTimelineStaging.ts b/packages/ui/src/hooks/useTimelineStaging.ts new file mode 100644 index 00000000..abec4ee2 --- /dev/null +++ b/packages/ui/src/hooks/useTimelineStaging.ts @@ -0,0 +1,114 @@ +import { useState, useRef, useEffect, useMemo } from "react" + +type StageConfig = { + /** How many messages to show on first paint */ + init: number + /** How many to add per animation frame */ + batch: number +} + +type UseTimelineStagingInput = { + /** Key that changes when session switches */ + sessionKey: string + /** All messages (sorted) */ + messages: T[] + /** Config for staging behavior */ + config?: StageConfig +} + +type UseTimelineStagingResult = { + /** The subset of messages that should be rendered */ + stagedMessages: T[] + /** Whether staging is still in progress */ + isStaging: boolean +} + +const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 } + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages — backfill and + * new messages render immediately. + * + * Defers mounting older turns so first paint isn't blocked by large DOM. + */ +export function useTimelineStaging( + input: UseTimelineStagingInput, +): UseTimelineStagingResult { + const config = input.config ?? DEFAULT_CONFIG + const { sessionKey, messages } = input + + const [stagedCount, setStagedCount] = useState(() => messages.length) + const completedSessions = useRef(new Set()) + const activeSession = useRef("") + const frameRef = useRef(null) + + useEffect(() => { + // Cancel any pending animation frame + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + const total = messages.length + + // If already completed for this session, show all immediately + if (completedSessions.current.has(sessionKey)) { + setStagedCount(total) + return + } + + // Small message list — no staging needed + if (total <= config.init) { + setStagedCount(total) + completedSessions.current.add(sessionKey) + return + } + + // Start staging + activeSession.current = sessionKey + let count = Math.min(total, config.init) + setStagedCount(count) + + const step = () => { + // Session changed mid-staging — bail + if (activeSession.current !== sessionKey) { + frameRef.current = null + return + } + + count = Math.min(messages.length, count + config.batch) + setStagedCount(count) + + if (count >= messages.length) { + completedSessions.current.add(sessionKey) + activeSession.current = "" + frameRef.current = null + return + } + + frameRef.current = requestAnimationFrame(step) + } + + frameRef.current = requestAnimationFrame(step) + + return () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } + }, [sessionKey, messages.length, config.init, config.batch]) + + const stagedMessages = useMemo(() => { + if (stagedCount >= messages.length) return messages + return messages.slice(Math.max(0, messages.length - stagedCount)) + }, [messages, stagedCount]) + + const isStaging = activeSession.current === sessionKey && + !completedSessions.current.has(sessionKey) + + return { stagedMessages, isStaging } +} diff --git a/packages/ui/src/hooks/useVoiceContext.ts b/packages/ui/src/hooks/useVoiceContext.ts index d4e1fa47..1e83273d 100644 --- a/packages/ui/src/hooks/useVoiceContext.ts +++ b/packages/ui/src/hooks/useVoiceContext.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context'; import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; /** @@ -7,45 +8,41 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; * Call this inside VoiceProvider to enable session awareness during voice. */ export function useVoiceContext() { - const currentSessionId = useSessionStore((s) => s.currentSessionId); - const messages = useSessionStore((s) => - currentSessionId ? s.messages.get(currentSessionId) : undefined - ); - const permissions = useSessionStore((s) => - currentSessionId ? s.permissions.get(currentSessionId) : undefined - ); - + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const messages = useSessionMessageRecords(currentSessionId ?? ''); + const permissions = useSessionPermissions(currentSessionId ?? ''); + // Track last seen message count to only forward new messages const lastMessageCountRef = useRef(0); - + // Forward new messages to voice agent useEffect(() => { - if (!currentSessionId || !messages || !isVoiceSessionStarted()) return; - + if (!currentSessionId || !messages || messages.length === 0 || !isVoiceSessionStarted()) return; + const currentCount = messages.length; if (currentCount <= lastMessageCountRef.current) return; - + // Get only new messages (messages since last check) const newMessages = messages.slice(lastMessageCountRef.current); lastMessageCountRef.current = currentCount; - + // Format for voice hooks (extract role and content) const formattedMessages = newMessages.map(m => ({ role: m.info.role, - content: m.parts.map(p => ('text' in p ? p.text : '')).join('') + content: m.parts.map((p: Record) => ('text' in p ? p.text : '')).join('') })); - + voiceHooks.onMessages(currentSessionId, formattedMessages); }, [currentSessionId, messages]); - + // Forward permission requests to voice agent useEffect(() => { if (!currentSessionId || !permissions || permissions.length === 0) return; if (!isVoiceSessionStarted()) return; - + const request = permissions[0]; if (!request) return; - + voiceHooks.onPermissionRequested( currentSessionId, request.id, @@ -53,7 +50,7 @@ export function useVoiceContext() { request.metadata ); }, [currentSessionId, permissions]); - + // Reset message count when session changes useEffect(() => { lastMessageCountRef.current = 0; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7ed04f19..a67b2496 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -394,7 +394,7 @@ export interface GitWorktreeAPI { export interface GitAPI { checkIsGitRepository(directory: string): Promise; - getGitStatus(directory: string): Promise; + getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; revertGitFile(directory: string, filePath: string): Promise; @@ -536,6 +536,7 @@ export interface SettingsPayload { notificationMode?: 'always' | 'hidden-only'; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + sessionRetentionAction?: 'archive' | 'delete'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index ae1d5911..1e492ba6 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -23,6 +23,7 @@ type AppearanceSlice = { maxLastMessageLength: number; autoDeleteEnabled: boolean; autoDeleteAfterDays: number; + sessionRetentionAction: 'archive' | 'delete'; fontSize: number; terminalFontSize: number; padding: number; @@ -57,6 +58,7 @@ export const startAppearanceAutoSave = (): void => { maxLastMessageLength: useUIStore.getState().maxLastMessageLength, autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, + sessionRetentionAction: useUIStore.getState().sessionRetentionAction, fontSize: useUIStore.getState().fontSize, terminalFontSize: useUIStore.getState().terminalFontSize, padding: useUIStore.getState().padding, @@ -103,6 +105,7 @@ export const startAppearanceAutoSave = (): void => { maxLastMessageLength: state.maxLastMessageLength, autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, + sessionRetentionAction: state.sessionRetentionAction, fontSize: state.fontSize, terminalFontSize: state.terminalFontSize, padding: state.padding, @@ -159,6 +162,9 @@ export const startAppearanceAutoSave = (): void => { if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) { diff.autoDeleteAfterDays = current.autoDeleteAfterDays; } + if (current.sessionRetentionAction !== previous.sessionRetentionAction) { + diff.sessionRetentionAction = current.sessionRetentionAction; + } if (current.fontSize !== previous.fontSize) { diff.fontSize = current.fontSize; } diff --git a/packages/ui/src/lib/concurrency.ts b/packages/ui/src/lib/concurrency.ts new file mode 100644 index 00000000..e402a3c5 --- /dev/null +++ b/packages/ui/src/lib/concurrency.ts @@ -0,0 +1,27 @@ +export const mapWithConcurrency = async ( + values: T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise => { + if (values.length === 0) { + return []; + } + + const safeConcurrency = Math.max(1, Math.min(concurrency, values.length)); + const results = new Array(values.length); + let cursor = 0; + + const worker = async () => { + while (true) { + const nextIndex = cursor; + cursor += 1; + if (nextIndex >= values.length) { + return; + } + results[nextIndex] = await mapper(values[nextIndex]); + } + }; + + await Promise.all(Array.from({ length: safeConcurrency }, () => worker())); + return results; +}; diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 9c82de7a..144afb4d 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -1,11 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; +import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs'; +import { useStreamingStore } from '@/sync/streaming'; export interface DebugMessageInfo { messageId: string; @@ -28,7 +30,7 @@ export interface DebugMessageInfo { export const debugUtils = { getLastAssistantMessage(): DebugMessageInfo | null { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -36,7 +38,7 @@ export const debugUtils = { return null; } - const messages = state.messages.get(currentSessionId); + const messages = getSyncMessages(currentSessionId); if (!messages || messages.length === 0) { console.log('[ERROR] No messages in current session'); return null; @@ -44,8 +46,9 @@ export const debugUtils = { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.info.role === 'assistant') { - const parts = msg.parts.map((part: any) => { + if (msg.role === 'assistant') { + const msgParts = getSyncParts(msg.id) || []; + const parts = msgParts.map((part: any) => { const info: any = { id: part.id, type: part.type, @@ -71,9 +74,9 @@ export const debugUtils = { const isEmptyResponse = !hasText && !hasTools && (!isEmpty || hasStepMarkers); const info: DebugMessageInfo = { - messageId: msg.info.id, - role: msg.info.role, - timestamp: msg.info.time?.created || 0, + messageId: msg.id, + role: msg.role, + timestamp: (msg as any).time?.created || 0, partsCount: parts.length, parts, isEmpty, @@ -124,7 +127,7 @@ export const debugUtils = { truncateMessages(messages: any[]): any[] { return messages.map((msg) => ({ ...msg, - parts: (msg.parts || []).map((part: any) => { + parts: (getSyncParts(msg.id) || []).map((part: any) => { const truncatedPart: any = { ...part }; if ('text' in part) { @@ -157,7 +160,7 @@ export const debugUtils = { }, getAllMessages(truncate: boolean = false) { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -165,24 +168,25 @@ export const debugUtils = { return []; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); console.log(`[MESSAGES] Total messages in session: ${messages.length}`); messages.forEach((msg, idx) => { - console.log(`[${idx}] ${msg.info.role} - ${msg.info.id} - ${msg.parts.length} parts`); + const msgParts = getSyncParts(msg.id) || []; + console.log(`[${idx}] ${msg.role} - ${msg.id} - ${msgParts.length} parts`); }); - return truncate ? this.truncateMessages(messages) : messages; + return truncate ? this.truncateMessages(messages as any[]) : messages; }, async getAppStatus() { const directoryState = useDirectoryStore.getState(); - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const projectsState = useProjectsStore.getState(); const currentDirectory = directoryState.currentDirectory || null; const opencodeDirectory = opencodeClient.getDirectory() ?? null; - const sessions = sessionState.sessions || []; + const sessions = getSyncSessions(); const sessionDirectories = new Set(); const sessionDirectoryCounts: Record = {}; @@ -410,24 +414,25 @@ export const debugUtils = { }, getStreamingState() { - const state = useSessionStore.getState(); - const currentStreamingId = state.currentSessionId - ? state.streamingMessageIds.get(state.currentSessionId) ?? null + const sessionState = useSessionUIStore.getState(); + const streamingState = useStreamingStore.getState(); + const currentStreamingId = sessionState.currentSessionId + ? streamingState.streamingMessageIds.get(sessionState.currentSessionId) ?? null : null; console.log('[STREAM] Streaming State:', { streamingMessageId: currentStreamingId, - streamingMessageIds: Array.from(state.streamingMessageIds.entries()), - messageStreamStates: Array.from(state.messageStreamStates.entries()), + streamingMessageIds: Array.from(streamingState.streamingMessageIds.entries()), + messageStreamStates: Array.from(streamingState.messageStreamStates.entries()), }); return { streamingMessageId: currentStreamingId, - streamingMessageIds: state.streamingMessageIds, - streamStates: state.messageStreamStates, + streamingMessageIds: streamingState.streamingMessageIds, + streamStates: streamingState.messageStreamStates, }; }, findEmptyMessages() { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -435,11 +440,11 @@ export const debugUtils = { return []; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); const emptyMessages = messages - .filter((msg) => msg.info.role === 'assistant') + .filter((msg) => msg.role === 'assistant') .filter((msg) => { - const parts = msg.parts || []; + const parts = getSyncParts(msg.id) || []; const hasTextContent = parts.some( (p: any) => p.type === 'text' && p.text && p.text.trim().length > 0 ); @@ -451,12 +456,13 @@ export const debugUtils = { console.log(`[INSPECT] Found ${emptyMessages.length} empty assistant messages`); emptyMessages.forEach((msg, idx) => { + const parts = getSyncParts(msg.id) || []; console.log(`[${idx}] Empty message:`, { - messageId: msg.info.id, - partsCount: msg.parts.length, - provider: (msg.info as any).providerID, - model: (msg.info as any).modelID, - timestamp: msg.info.time?.created, + messageId: msg.id, + partsCount: parts.length, + provider: (msg as any).providerID, + model: (msg as any).modelID, + timestamp: (msg as any).time?.created, }); }); @@ -486,7 +492,7 @@ export const debugUtils = { maxTableRows?: number; } = {}) { const { includeNonAssistant = false, verbose = true, maxTableRows = 25 } = options; - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -494,10 +500,10 @@ export const debugUtils = { return { summary: null, rows: [] }; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); const targetMessages = includeNonAssistant ? messages - : messages.filter((msg) => msg.info.role === 'assistant'); + : messages.filter((msg) => msg.role === 'assistant'); const summary = { totalMessages: messages.length, @@ -520,8 +526,8 @@ export const debugUtils = { }; const rows = targetMessages.map((message, index) => { - const info = message.info ?? {}; - const parts = Array.isArray(message.parts) ? message.parts : []; + const info = message as any; + const parts = getSyncParts(message.id) || []; const timeInfo = (info.time ?? {}) as { completed?: number }; const completedAt = toNumber(timeInfo.completed); @@ -615,7 +621,7 @@ export const debugUtils = { }, checkCompletionStatus() { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -623,8 +629,8 @@ export const debugUtils = { return null; } - const messages = state.messages.get(currentSessionId) || []; - const assistantMessages = messages.filter(m => m.info.role === 'assistant'); + const messages = getSyncMessages(currentSessionId); + const assistantMessages = messages.filter(m => m.role === 'assistant'); if (assistantMessages.length === 0) { console.log('[ERROR] No assistant messages'); @@ -632,24 +638,25 @@ export const debugUtils = { } const lastMessage = assistantMessages[assistantMessages.length - 1]; - const stepFinishParts = lastMessage.parts.filter((p: any) => p.type === 'step-finish'); - const hasStopReason = (lastMessage.info as { finish?: string }).finish === 'stop'; + const lastParts = getSyncParts(lastMessage.id) || []; + const stepFinishParts = lastParts.filter((p: any) => p.type === 'step-finish'); + const hasStopReason = (lastMessage as any).finish === 'stop'; - const timeInfo = lastMessage.info.time as any; + const timeInfo = (lastMessage as any).time; const completedAt = timeInfo?.completed; - const messageStatus = (lastMessage.info as any).status; + const messageStatus = (lastMessage as any).status; const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed'; const messageIsComplete = Boolean(hasCompletedFlag && hasStopReason); - const messageStreamStates = state.messageStreamStates; - const streamingMessageId = (lastMessage.info as { sessionID?: string }).sessionID - ? state.streamingMessageIds.get((lastMessage.info as { sessionID?: string }).sessionID as string) ?? null + const streamingState = useStreamingStore.getState(); + const streamingMessageId = (lastMessage as any).sessionID + ? streamingState.streamingMessageIds.get((lastMessage as any).sessionID as string) ?? null : null; - const lifecycle = messageStreamStates.get(lastMessage.info.id); - const isStreamingCandidate = lastMessage.info.id === streamingMessageId; + const lifecycle = streamingState.messageStreamStates.get(lastMessage.id); + const isStreamingCandidate = lastMessage.id === streamingMessageId; console.log('[SUMMARY] Completion Status:'); - console.log('Message ID:', lastMessage.info.id); + console.log('Message ID:', lastMessage.id); console.log('time.completed:', completedAt, '(type:', typeof completedAt, ')'); console.log('status:', messageStatus); console.log('hasCompletedFlag:', hasCompletedFlag); @@ -661,7 +668,7 @@ export const debugUtils = { console.log('Step-finish parts:', stepFinishParts); return { - messageId: lastMessage.info.id, + messageId: lastMessage.id, completed: completedAt, status: messageStatus, hasCompletedFlag, diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 662b821b..36e3b026 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -94,6 +94,7 @@ export type DesktopSettings = { }>; // Per-provider custom model groups configuration autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + sessionRetentionAction?: 'archive' | 'delete'; tunnelProvider?: string; tunnelMode?: 'quick' | 'managed-remote' | 'managed-local'; tunnelBootstrapTtlMs?: number | null; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 6dd2d6c2..be11f5c8 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -3,7 +3,7 @@ import type { RuntimeAPIs } from './api/types'; import * as gitHttp from './gitApiHttp'; import { opencodeClient } from './opencode/client'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -56,10 +56,10 @@ export async function checkIsGitRepository(directory: string): Promise return gitHttp.checkIsGitRepository(directory); } -export async function getGitStatus(directory: string): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitStatus(directory); - return gitHttp.getGitStatus(directory); + return gitHttp.getGitStatus(directory, options); } export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise { @@ -326,7 +326,7 @@ type SessionGenerationContext = { }; const resolveSessionGenerationContext = (): SessionGenerationContext | null => { - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { return null; } diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 89983135..8288ed2e 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -119,8 +119,9 @@ export async function checkIsGitRepository(directory: string): Promise } } -export async function getGitStatus(directory: string): Promise { - const key = normalizeDirectoryKey(directory); +export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { + const mode = options?.mode; + const key = mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory); const now = Date.now(); const cached = gitStatusCache.get(key); if (cached && cached.expiresAt > now) { @@ -133,7 +134,7 @@ export async function getGitStatus(directory: string): Promise { } const task = (async () => { - const response = await fetch(buildUrl(`${API_BASE}/status`, directory)); + const response = await fetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined)); if (!response.ok) { throw new Error(`Failed to get git status: ${response.statusText}`); } diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 0527d2b5..2de11c9a 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -1,4 +1,5 @@ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncSessions } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; declare const __APP_VERSION__: string | undefined; @@ -37,10 +38,11 @@ type OpenChamberOpencodeResolution = { }; const getCurrentDirectory = (): string => { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) return ''; - const session = state.sessions.find((s) => s.id === currentSessionId); + const sessions = getSyncSessions(); + const session = sessions.find((s) => s.id === currentSessionId); return typeof session?.directory === 'string' ? session.directory : ''; }; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index a15bccac..394e0c42 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -11,22 +11,10 @@ import type { Agent, TextPartInput, FilePartInput, - Event, } from "@opencode-ai/sdk/v2"; import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; -type StreamEvent = { - data: TData; - event?: string; - id?: string; - retry?: number; -}; - -export type RoutedOpencodeEvent = { - directory: string; - payload: Event; -}; // Use relative path by default (works with both dev and nginx proxy server) // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments @@ -147,22 +135,8 @@ class OpencodeService { private client: OpencodeClient; private baseUrl: string; private scopedClients: Map = new Map(); - private sseAbortControllers: Map = new Map(); private currentDirectory: string | undefined = undefined; private directoryContextQueue: Promise = Promise.resolve(); - - private globalSseAbortController: AbortController | null = null; - private globalSseTask: Promise | null = null; - private globalSseIsConnected = false; - private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set(); - private globalSseOpenListeners: Set<() => void> = new Set(); - private globalSseErrorListeners: Set<(error: unknown) => void> = new Set(); - private globalSseQueue: Array = []; - private globalSseBuffer: Array = []; - private globalSseCoalesced: Map = new Map(); - private globalSseStaleDeltas: Set = new Set(); - private globalSseFlushTimer: ReturnType | null = null; - private globalSseLastFlushAt = 0; private listDirectoryInFlight: Map> = new Map(); private listDirectoryCache: Map = new Map(); @@ -177,6 +151,16 @@ class OpencodeService { return this.baseUrl; } + /** Expose the raw SDK client for direct use (e.g., SyncProvider) */ + getSdkClient(): OpencodeClient { + return this.client; + } + + /** Get a scoped SDK client for a specific directory */ + getScopedSdkClient(directory: string): OpencodeClient { + return this.getScopedApiClient(directory); + } + /** * Returns an SDK client scoped to a project directory. * Needed for worktree APIs where backend ignores per-call directory. @@ -756,6 +740,7 @@ class OpencodeService { }, agent: params.agent, variant: params.variant, + ...(params.messageId ? { messageID: params.messageId } : {}), ...(params.format ? { format: params.format } : {}), parts, }), @@ -1213,648 +1198,8 @@ class OpencodeService { } } - private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null { - if (!raw || typeof raw !== 'object') { - return null; - } - - const record = raw as Record; - - const directoryCandidate = - typeof record.directory === 'string' - ? record.directory - : typeof record.properties === 'object' && record.properties !== null - ? ((record.properties as Record).directory as unknown) - : null; - - const normalizedDirectory = - typeof directoryCandidate === 'string' - ? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim() - : null; - - if (typeof record.type === 'string') { - return { - directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', - payload: record as Event, - }; - } - - const nestedPayload = record.payload; - if (nestedPayload && typeof nestedPayload === 'object') { - const nestedRecord = nestedPayload as Record; - if (typeof nestedRecord.type === 'string') { - return { - directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', - payload: nestedRecord as Event, - }; - } - } - - return null; - } - - private emitGlobalSseEvent(event: RoutedOpencodeEvent) { - this.enqueueGlobalSseEvent(event); - } - - private notifyGlobalSseOpen() { - for (const handler of this.globalSseOpenListeners) { - try { - handler(); - } catch (error) { - console.warn('[OpencodeClient] Global SSE open handler error:', error); - } - } - } - - private notifyGlobalSseError(error: unknown) { - for (const handler of this.globalSseErrorListeners) { - try { - handler(error); - } catch (listenerError) { - console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError); - } - } - } - - private ensureGlobalSseStarted() { - if (this.globalSseTask) { - return; - } - - const abortController = new AbortController(); - this.globalSseAbortController = abortController; - - this.globalSseTask = this.runGlobalSseLoop(abortController) - .catch((error) => { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - console.error('[OpencodeClient] Global SSE task failed:', error); - }) - .finally(() => { - if (this.globalSseAbortController === abortController) { - this.globalSseAbortController = null; - } - this.globalSseTask = null; - this.globalSseIsConnected = false; - }); - } - - private maybeStopGlobalSse() { - if (this.globalSseListeners.size > 0) { - return; - } - - if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) { - this.globalSseAbortController.abort(); - } - this.globalSseAbortController = null; - this.clearGlobalSseQueue(); - } - - private clearGlobalSseQueue() { - if (this.globalSseFlushTimer) { - clearTimeout(this.globalSseFlushTimer); - this.globalSseFlushTimer = null; - } - this.globalSseQueue.length = 0; - this.globalSseBuffer.length = 0; - this.globalSseCoalesced.clear(); - this.globalSseStaleDeltas.clear(); - } - - private getGlobalSseDeltaKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (eventType !== 'message.part.delta') { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - const messageId = typeof properties?.messageID === 'string' - ? properties.messageID - : typeof properties?.messageId === 'string' - ? properties.messageId - : null; - const partId = typeof properties?.partID === 'string' - ? properties.partID - : typeof properties?.partId === 'string' - ? properties.partId - : null; - - if (!messageId || !partId) { - return null; - } - - return `${event.directory}:${messageId}:${partId}`; - } - - private getGlobalSseUpdatedPartKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (eventType !== 'message.part.updated') { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - const part = - properties?.part && typeof properties.part === 'object' - ? (properties.part as Record) - : null; - const messageId = typeof part?.messageID === 'string' - ? part.messageID - : typeof part?.messageId === 'string' - ? part.messageId - : null; - const partId = typeof part?.id === 'string' - ? part.id - : typeof part?.partID === 'string' - ? part.partID - : typeof part?.partId === 'string' - ? part.partId - : null; - - if (!messageId || !partId) { - return null; - } - - return `${event.directory}:${messageId}:${partId}`; - } - - private getGlobalSseCoalesceKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (!eventType) { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - - if (eventType === 'session.status') { - const sessionId = typeof properties?.sessionID === 'string' - ? properties.sessionID - : typeof properties?.sessionId === 'string' - ? properties.sessionId - : null; - if (!sessionId) { - return null; - } - return `session.status:${event.directory}:${sessionId}`; - } - - if (eventType === 'openchamber:session-status') { - const sessionId = typeof properties?.sessionId === 'string' - ? properties.sessionId - : typeof properties?.sessionID === 'string' - ? properties.sessionID - : null; - if (!sessionId) { - return null; - } - return `openchamber:session-status:${sessionId}`; - } - - if (eventType === 'message.part.updated') { - const partKey = this.getGlobalSseUpdatedPartKey(event); - if (!partKey) { - return null; - } - return `message.part.updated:${partKey}`; - } - - return null; - } - - private flushGlobalSseQueue = () => { - if (this.globalSseFlushTimer) { - clearTimeout(this.globalSseFlushTimer); - this.globalSseFlushTimer = null; - } - - if (this.globalSseQueue.length === 0) { - return; - } - - const events = this.globalSseQueue; - const skip = this.globalSseStaleDeltas.size > 0 ? new Set(this.globalSseStaleDeltas) : undefined; - this.globalSseQueue = this.globalSseBuffer; - this.globalSseBuffer = events; - this.globalSseQueue.length = 0; - this.globalSseCoalesced.clear(); - this.globalSseStaleDeltas.clear(); - this.globalSseLastFlushAt = Date.now(); - - for (const event of events) { - if (!event) continue; - if (skip) { - const deltaKey = this.getGlobalSseDeltaKey(event); - if (deltaKey && skip.has(deltaKey)) { - continue; - } - } - for (const listener of this.globalSseListeners) { - try { - listener(event); - } catch (error) { - console.warn('[OpencodeClient] Global SSE listener error:', error); - } - } - } - - this.globalSseBuffer.length = 0; - }; - - private scheduleGlobalSseFlush() { - if (this.globalSseFlushTimer) { - return; - } - const elapsed = Date.now() - this.globalSseLastFlushAt; - const delay = Math.max(0, 16 - elapsed); - this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay); - } - - private enqueueGlobalSseEvent(event: RoutedOpencodeEvent) { - const key = this.getGlobalSseCoalesceKey(event); - if (key) { - const existingIndex = this.globalSseCoalesced.get(key); - if (existingIndex !== undefined) { - this.globalSseQueue[existingIndex] = undefined; - const updatedPartKey = this.getGlobalSseUpdatedPartKey(event); - if (updatedPartKey) { - this.globalSseStaleDeltas.add(updatedPartKey); - } - } - this.globalSseCoalesced.set(key, this.globalSseQueue.length); - } - - this.globalSseQueue.push(event); - this.scheduleGlobalSseFlush(); - } - - private async runGlobalSseLoop(abortController: AbortController): Promise { - let attempt = 0; - const RECONNECT_DELAY_MS = 250; - const STREAM_YIELD_MS = 8; - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - while (!abortController.signal.aborted) { - try { - const result = await this.client.global.event({ - signal: abortController.signal, - onSseError: (error: unknown) => { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - this.notifyGlobalSseError(error); - }, - }); - - attempt = 0; - this.globalSseIsConnected = true; - if (!abortController.signal.aborted) { - this.notifyGlobalSseOpen(); - } - - let yielded = Date.now(); - - for await (const event of result.stream) { - if (abortController.signal.aborted) { - break; - } - - const directory = typeof event.directory === 'string' && event.directory.length > 0 - ? event.directory - : 'global'; - const routed = this.normalizeRoutedSsePayload({ - directory, - payload: event.payload, - }); - if (!routed) { - continue; - } - - this.emitGlobalSseEvent(routed); - if (Date.now() - yielded >= STREAM_YIELD_MS) { - yielded = Date.now(); - await wait(0); - } - } - - this.globalSseIsConnected = false; - } catch (error: unknown) { - this.globalSseIsConnected = false; - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - console.error('[OpencodeClient] Global SSE stream error (will retry):', error); - this.notifyGlobalSseError(error); - } - - if (abortController.signal.aborted) { - break; - } - - attempt += 1; - await wait(Math.min(RECONNECT_DELAY_MS * Math.max(attempt, 1), 2000)); - } - - this.flushGlobalSseQueue(); - } - - subscribeToGlobalEvents( - onEvent: (event: RoutedOpencodeEvent) => void, - onError?: (error: unknown) => void, - onOpen?: () => void, - options?: { directory?: string | null } - ): () => void { - const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null); - const listener = (event: RoutedOpencodeEvent) => { - if (directoryFilter && event.directory !== directoryFilter) { - return; - } - onEvent(event); - }; - - this.globalSseListeners.add(listener); - - if (onOpen) { - this.globalSseOpenListeners.add(onOpen); - if (this.globalSseIsConnected) { - setTimeout(() => { - if (this.globalSseOpenListeners.has(onOpen)) { - try { - onOpen(); - } catch (error) { - console.warn('[OpencodeClient] Global SSE open handler error:', error); - } - } - }, 0); - } - } - - if (onError) { - this.globalSseErrorListeners.add(onError); - } - - this.ensureGlobalSseStarted(); - - return () => { - this.globalSseListeners.delete(listener); - if (onOpen) { - this.globalSseOpenListeners.delete(onOpen); - } - if (onError) { - this.globalSseErrorListeners.delete(onError); - } - this.maybeStopGlobalSse(); - }; - } - - // Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator - subscribeToEvents( - onMessage: (event: { type: string; properties?: Record }) => void, - onError?: (error: unknown) => void, - onOpen?: () => void, - directoryOverride?: string | null, - options?: { scope?: 'global' | 'directory'; key?: string } - ): () => void { - const subscriptionKey = options?.key ?? 'default'; - const scope = options?.scope ?? 'directory'; - const existingController = this.sseAbortControllers.get(subscriptionKey); - if (existingController) { - existingController.abort(); - } - - // Create new AbortController for this subscription - const abortController = new AbortController(); - this.sseAbortControllers.set(subscriptionKey, abortController); - - let lastEventId: string | undefined; - - if (scope === 'global') { - let globalUnsub: (() => void) | null = null; - - const attachDirectory = (event: RoutedOpencodeEvent): Event => { - if (event.directory === 'global') { - return event.payload; - } - - const payloadRecord = event.payload as unknown as Record; - const existingProperties = - typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null - ? (payloadRecord.properties as Record) - : {}; - - if (existingProperties.directory === event.directory) { - return event.payload; - } - - return { - ...payloadRecord, - properties: { - ...existingProperties, - directory: event.directory, - }, - } as Event; - }; - - const cleanup = () => { - if (globalUnsub) { - try { - globalUnsub(); - } catch { - // ignore - } - globalUnsub = null; - } - - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - }; - - abortController.signal.addEventListener('abort', cleanup, { once: true }); - - globalUnsub = this.subscribeToGlobalEvents( - (event) => { - if (abortController.signal.aborted) { - return; - } - onMessage(attachDirectory(event)); - }, - onError - ? (error) => { - if (!abortController.signal.aborted) { - onError(error); - } - } - : undefined, - onOpen - ? () => { - if (!abortController.signal.aborted) { - onOpen(); - } - } - : undefined, - ); - - return () => { - cleanup(); - abortController.abort(); - }; - } - - const normalizeEventPayload = (payload: unknown): Event | null => { - if (!payload || typeof payload !== 'object') { - return null; - } - - const record = payload as Record; - if (typeof record.type === 'string') { - return record as Event; - } - - const nestedPayload = record.payload; - if (nestedPayload && typeof nestedPayload === 'object') { - const nestedRecord = nestedPayload as Record; - if (typeof nestedRecord.type === 'string') { - if (typeof record.directory === 'string' && record.directory.length > 0) { - const existingProperties = - typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null - ? (nestedRecord.properties as Record) - : null; - const properties = { - ...(existingProperties ?? {}), - directory: record.directory, - }; - return { ...nestedRecord, properties } as Event; - } - return nestedRecord as Event; - } - } - - return null; - }; - - - console.log('[OpencodeClient] Starting SSE subscription...'); - - // Start async generator in background with reconnect on failure - (async () => { - const resolvedDirectory = - typeof directoryOverride === 'string' && directoryOverride.trim().length > 0 - ? directoryOverride.trim() - : this.currentDirectory; - - console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default'); - - const connect = async (attempt: number): Promise => { - try { - const subscribeParameters = resolvedDirectory ? { directory: resolvedDirectory } : undefined; - const subscribeOptions: { - signal: AbortSignal; - sseDefaultRetryDelay: number; - sseMaxRetryDelay: number; - onSseError?: (error: unknown) => void; - onSseEvent: (event: StreamEvent) => void; - headers?: Record; - } = { - signal: abortController.signal, - sseDefaultRetryDelay: 3000, - sseMaxRetryDelay: 30000, - onSseError: (error: unknown) => { - if (error instanceof Error && error.name === 'AbortError') { - return; - } - console.error('[OpencodeClient] SSE error:', error); - if (onError && !abortController.signal.aborted) { - onError(error); - } - }, - onSseEvent: (event: StreamEvent) => { - if (abortController.signal.aborted) return; - if (event.id && typeof event.id === 'string') { - lastEventId = event.id; - } - const payload = event.data; - const normalized = normalizeEventPayload(payload); - if (normalized) { - onMessage(normalized); - } - }, - }; - - if (lastEventId) { - subscribeOptions.headers = { ...(subscribeOptions.headers || {}), 'Last-Event-ID': lastEventId }; - } - - const result = await this.client.event.subscribe(subscribeParameters, subscribeOptions); - - if (onOpen && !abortController.signal.aborted) { - console.log('[OpencodeClient] SSE connection opened'); - onOpen(); - } - - for await (const _ of result.stream) { - void _; - if (abortController.signal.aborted) { - console.log('[OpencodeClient] SSE stream aborted'); - break; - } - } - } catch (error: unknown) { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - console.log('[OpencodeClient] SSE stream aborted normally'); - return; - } - console.error('[OpencodeClient] SSE stream error (will retry):', error); - if (onError) { - onError(error); - } - const delay = Math.min(3000 * Math.pow(2, attempt), 30000); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (!abortController.signal.aborted) { - await connect(attempt + 1); - } - return; - } - - if (!abortController.signal.aborted) { - const delay = Math.min(3000 * Math.pow(2, attempt), 30000); - await new Promise((resolve) => setTimeout(resolve, delay)); - await connect(attempt + 1); - } - }; - - try { - await connect(0); - } finally { - console.log('[OpencodeClient] SSE subscription cleanup'); - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - } - })(); - - // Return cleanup function - return () => { - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - abortController.abort(); - }; - - } + // SSE infrastructure removed — EventPipeline in sync/event-pipeline.ts handles + // all SSE event ingestion via the SDK's global.event() async iterator. // File Operations async readFile(path: string): Promise { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 81975005..dc97187a 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -311,6 +311,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setAutoDeleteAfterDays(normalized); } } + if (settings.sessionRetentionAction === 'archive' || settings.sessionRetentionAction === 'delete') { + if (settings.sessionRetentionAction !== store.sessionRetentionAction) { + store.setSessionRetentionAction(settings.sessionRetentionAction); + } + } if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { queueStore.setQueueMode(settings.queueModeEnabled); @@ -522,6 +527,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; } + if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') { + result.sessionRetentionAction = candidate.sessionRetentionAction; + } if (typeof candidate.tunnelProvider === 'string') { const provider = candidate.tunnelProvider.trim().toLowerCase(); if (provider.length > 0) { @@ -850,32 +858,57 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { return result; }; -const fetchWebSettings = async (): Promise => { - const runtimeSettings = getRuntimeSettingsAPI(); - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - return sanitizeWebSettings(result.settings); - } catch (error) { - console.warn('Failed to load shared settings from runtime settings API:', error); +// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup +let _settingsCache: { value: DesktopSettings | null; at: number } | null = null; +let _settingsInflight: Promise | null = null; +const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst - } +const fetchWebSettings = async (): Promise => { + // Return cached if fresh + if (_settingsCache && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) { + return _settingsCache.value; } - try { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { + // Dedup concurrent calls + if (_settingsInflight) return _settingsInflight; + + _settingsInflight = (async (): Promise => { + const runtimeSettings = getRuntimeSettingsAPI(); + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const settings = sanitizeWebSettings(result.settings); + _settingsCache = { value: settings, at: Date.now() }; + return settings; + } catch (error) { + console.warn('Failed to load shared settings from runtime settings API:', error); + } + } + + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return null; + } + const data = await response.json().catch(() => null); + const settings = sanitizeWebSettings(data); + _settingsCache = { value: settings, at: Date.now() }; + return settings; + } catch (error) { + console.warn('Failed to load shared settings from server:', error); return null; } - const data = await response.json().catch(() => null); - return sanitizeWebSettings(data); - } catch (error) { - console.warn('Failed to load shared settings from server:', error); - return null; - } + })().finally(() => { _settingsInflight = null; }); + + return _settingsInflight; +}; + +/** Invalidate cached settings (call after a successful PUT) */ +export const invalidateSettingsCache = (): void => { + _settingsCache = null; }; export const syncDesktopSettings = async (): Promise => { @@ -916,12 +949,16 @@ export const syncDesktopSettings = async (): Promise => { } }; -export const updateDesktopSettings = async (changes: Partial): Promise => { - if (typeof window === 'undefined') { - return; - } +// Coalesce rapid updateDesktopSettings calls into a single PUT +let _pendingSettingsChanges: Partial | null = null; +let _settingsFlushTimer: ReturnType | null = null; +const SETTINGS_DEBOUNCE_MS = 200; - // Desktop shell uses the same HTTP settings API as web. +const _flushSettingsUpdate = async (): Promise => { + const changes = _pendingSettingsChanges; + _pendingSettingsChanges = null; + _settingsFlushTimer = null; + if (!changes || Object.keys(changes).length === 0) return; const runtimeSettings = getRuntimeSettingsAPI(); if (runtimeSettings) { @@ -956,12 +993,27 @@ export const updateDesktopSettings = async (changes: Partial): if (updated) { persistToLocalStorage(updated); applyDesktopUiPreferences(updated); + // Invalidate GET cache so next read sees the fresh data + _settingsCache = null; } } catch (error) { console.warn('Failed to update shared settings via API:', error); } }; +export const updateDesktopSettings = async (changes: Partial): Promise => { + if (typeof window === 'undefined') { + return; + } + + _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes }; + + if (_settingsFlushTimer) { + clearTimeout(_settingsFlushTimer); + } + _settingsFlushTimer = setTimeout(() => void _flushSettingsUpdate(), SETTINGS_DEBOUNCE_MS); +}; + export const initializeAppearancePreferences = async (): Promise => { if (typeof window === 'undefined') { return; diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts new file mode 100644 index 00000000..c047ca64 --- /dev/null +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -0,0 +1,129 @@ +import Fuse from "fuse.js"; + +export interface FuzzySearchOptions { + threshold?: number; + distance?: number; + ignoreLocation?: boolean; + preferSubstring?: boolean; +} + +const DEFAULT_FUZZY_OPTIONS: Required = { + threshold: 0.4, + distance: 100, + ignoreLocation: true, + preferSubstring: true, +}; + +export function matchesFuzzyQuery( + target: string, + query: string, + options?: FuzzySearchOptions +): boolean { + if (!query) { + return true; + } + if (!target) { + return false; + } + + const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; + + if (mergedOptions.preferSubstring && target.toLowerCase().includes(query.toLowerCase())) { + return true; + } + + const fuse = new Fuse([target], { + threshold: mergedOptions.threshold, + distance: mergedOptions.distance, + ignoreLocation: mergedOptions.ignoreLocation, + }); + + return fuse.search(query).length > 0; +} + +function getFuzzyMatchMask( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): boolean[] { + if (!query) { + return items.map(() => true); + } + + const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; + const queryLower = query.toLowerCase(); + const matches = new Array(items.length).fill(false); + const fuzzyCandidateTexts: string[] = []; + const fuzzyCandidateIndices: number[] = []; + + for (let i = 0; i < items.length; i++) { + const target = getText(items[i]); + if (!target) { + continue; + } + + if (mergedOptions.preferSubstring && target.toLowerCase().includes(queryLower)) { + matches[i] = true; + continue; + } + + fuzzyCandidateTexts.push(target); + fuzzyCandidateIndices.push(i); + } + + if (fuzzyCandidateTexts.length === 0) { + return matches; + } + + const fuse = new Fuse(fuzzyCandidateTexts, { + threshold: mergedOptions.threshold, + distance: mergedOptions.distance, + ignoreLocation: mergedOptions.ignoreLocation, + }); + + for (const result of fuse.search(query)) { + matches[fuzzyCandidateIndices[result.refIndex]] = true; + } + + return matches; +} + +export function filterByFuzzyQuery( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): T[] { + const matches = getFuzzyMatchMask(items, query, getText, options); + const matching: T[] = []; + + for (let i = 0; i < items.length; i++) { + if (matches[i]) { + matching.push(items[i]); + } + } + + return matching; +} + +export function partitionByFuzzyQuery( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): { matching: T[]; other: T[] } { + const matches = getFuzzyMatchMask(items, query, getText, options); + const matching: T[] = []; + const other: T[] = []; + + for (let i = 0; i < items.length; i++) { + if (matches[i]) { + matching.push(items[i]); + continue; + } + other.push(items[i]); + } + + return { matching, other }; +} diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 61b3a011..d7dce064 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -230,13 +230,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open git panel', description: 'Switch to the git panel', }, - { - id: 'open_timeline', - defaultCombo: 'mod+t', - label: 'Open timeline', - description: 'Open the timeline dialog', - customizable: true, - }, { id: 'open_help', defaultCombo: 'mod+.', diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 687852d6..97d317b4 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { isTauriShell } from "@/lib/desktop"; +import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -129,24 +130,10 @@ export function formatDirectoryName(path: string | null | undefined, homeDirecto return name || "/"; } -import Fuse from 'fuse.js'; - /** * Fuzzy search using Fuse.js with typo tolerance. * Returns true if query fuzzy-matches target (e.g. "coude" matches "claude") */ export function fuzzyMatch(target: string, query: string): boolean { - if (!query) return true; - if (!target) return false; - - // Quick exact substring check first - if (target.toLowerCase().includes(query.toLowerCase())) return true; - - const fuse = new Fuse([target], { - threshold: 0.4, // 0 = exact, 1 = match anything - distance: 100, - ignoreLocation: true, - }); - const results = fuse.search(query); - return results.length > 0; + return matchesFuzzyQuery(target, query); } diff --git a/packages/ui/src/lib/voice/realtimeClientTools.ts b/packages/ui/src/lib/voice/realtimeClientTools.ts index 795b332e..7778ddbf 100644 --- a/packages/ui/src/lib/voice/realtimeClientTools.ts +++ b/packages/ui/src/lib/voice/realtimeClientTools.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -import { useSessionStore } from "@/stores/useSessionStore"; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from "@/stores/useConfigStore"; -import { usePermissionStore } from "@/stores/permissionStore"; +import { getSyncPermissions } from "@/sync/sync-refs"; +import { respondToPermission } from "@/sync/session-actions"; /** * Static client tools for the realtime voice interface. @@ -25,7 +26,7 @@ export const realtimeClientTools = { } // Get current session ID from store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { console.error("[Voice] No active session"); return "error (no active session)"; @@ -40,7 +41,7 @@ export const realtimeClientTools = { try { console.log("[Voice] Sending message to session:", sessionId); - await useSessionStore + await useSessionUIStore .getState() .sendMessage(parsed.data.message, currentProviderId, currentModelId, currentAgentName ?? undefined); return "sent"; @@ -67,14 +68,14 @@ export const realtimeClientTools = { } // Get current session ID from store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { console.error("[Voice] No active session"); return "error (no active session)"; } // Get pending permissions for this session - const permissions = usePermissionStore.getState().permissions.get(sessionId); + const permissions = getSyncPermissions(sessionId); if (!permissions || permissions.length === 0) { console.error("[Voice] No pending permission requests"); return "error (no pending permission request)"; @@ -92,7 +93,7 @@ export const realtimeClientTools = { // Respond to the permission based on decision const response: "once" | "always" | "reject" = decision === "allow" ? "once" : "reject"; - await usePermissionStore.getState().respondToPermission(sessionId, request.id, response); + await respondToPermission(sessionId, request.id, response); return "done"; } catch (error) { diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index f8a22fef..303e161d 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -5,7 +5,7 @@ */ import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; @@ -132,14 +132,13 @@ const initializeSessionForWorktree = (sessionId: string, metadata: { createdFromBranch?: string; kind?: 'pr' | 'standard'; }) => { - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const configState = useConfigStore.getState(); sessionStore.initializeNewOpenChamberSession(sessionId, configState.agents); sessionStore.setSessionDirectory(sessionId, metadata.path); sessionStore.setWorktreeMetadata(sessionId, metadata); applyDefaultAgentAndModelSelection(sessionId, configState); useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); - void sessionStore.loadSessions().catch(() => undefined); }; @@ -183,7 +182,7 @@ const createInstantWorktreeDraft = async (options?: { // Lock the draft immediately so no React effect can reset it to the project // root while we await the preview / worktree creation below. - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); if (sessionStore.newSessionDraft?.open) { sessionStore.overrideNewSessionDraftTarget({ projectId: projectRef.id, @@ -195,7 +194,7 @@ const createInstantWorktreeDraft = async (options?: { }); } else { sessionStore.openNewSessionDraft({ - projectId: projectRef.id, + selectedProjectId: projectRef.id, directoryOverride: projectRef.path, pendingWorktreeRequestId: pendingRequestId, preserveDirectoryOverride: true, @@ -214,7 +213,7 @@ const createInstantWorktreeDraft = async (options?: { // Refine draft target once we know the actual worktree path from the preview. if (preview?.path) { - useSessionStore.getState().overrideNewSessionDraftTarget({ + useSessionUIStore.getState().overrideNewSessionDraftTarget({ projectId: projectRef.id, directoryOverride: preview.path, pendingWorktreeRequestId: pendingRequestId, @@ -236,7 +235,7 @@ const createInstantWorktreeDraft = async (options?: { }); resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path); - useSessionStore.getState().overrideNewSessionDraftTarget({ + useSessionUIStore.getState().overrideNewSessionDraftTarget({ projectId: projectRef.id, directoryOverride: metadata.path, pendingWorktreeRequestId: null, @@ -246,17 +245,16 @@ const createInstantWorktreeDraft = async (options?: { initialPrompt: options?.initialPrompt, }); useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); - void useSessionStore.getState().loadSessions().catch(() => undefined); return metadata.path; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree'; - const requestId = useSessionStore.getState().newSessionDraft.pendingWorktreeRequestId; + const requestId = useSessionUIStore.getState().newSessionDraft.pendingWorktreeRequestId; if (requestId) { rejectPendingDraftWorktreeRequest(requestId, error instanceof Error ? error : new Error(message)); - useSessionStore.getState().resolvePendingDraftWorktreeTarget(requestId, null); + useSessionUIStore.getState().resolvePendingDraftWorktreeTarget(requestId, null); } - useSessionStore.getState().setDraftBootstrapPendingDirectory(null); + useSessionUIStore.getState().setDraftBootstrapPendingDirectory(null); toast.error('Failed to create worktree', { description: message, }); @@ -329,7 +327,6 @@ export async function createWorktreeOnly(): Promise { }); - void useSessionStore.getState().loadSessions().catch(() => undefined); return metadata.path; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree'; @@ -416,7 +413,7 @@ export async function createWorktreeSessionForBranch( }; // Create the session - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { // Clean up the worktree if session creation failed @@ -516,7 +513,7 @@ export async function createWorktreeSessionForNewBranch( kind, }; - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); diff --git a/packages/ui/src/lib/worktrees/branchSearch.ts b/packages/ui/src/lib/worktrees/branchSearch.ts new file mode 100644 index 00000000..3bf564cf --- /dev/null +++ b/packages/ui/src/lib/worktrees/branchSearch.ts @@ -0,0 +1,67 @@ +import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch"; + +export interface RankedBranchGroups { + matching: Array<{ + label: string; + value: string; + source: 'local' | 'remote'; + }>; + otherLocal: string[]; + otherRemote: string[]; +} + +export function rankBranchesForQuery(args: { + localBranches: string[]; + remoteBranches: string[]; + query: string; +}): RankedBranchGroups { + const { localBranches, remoteBranches, query } = args; + const normalizedQuery = query.trim(); + + if (!normalizedQuery) { + return { + matching: [], + otherLocal: localBranches, + otherRemote: remoteBranches, + }; + } + + const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch); + const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch); + const matching: RankedBranchGroups['matching'] = []; + const otherLocal = localPartition.other; + const otherRemote = remotePartition.other; + + for (const branch of localPartition.matching) { + matching.push({ + label: branch, + value: branch, + source: 'local', + }); + } + + for (const branch of remotePartition.matching) { + matching.push({ + label: branch, + value: `remotes/${branch}`, + source: 'remote', + }); + } + + matching.sort((a, b) => { + const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); + if (byLabel !== 0) { + return byLabel; + } + if (a.source !== b.source) { + return a.source.localeCompare(b.source); + } + return a.value.localeCompare(b.value); + }); + + return { + matching, + otherLocal, + otherRemote, + }; +} diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 6fa7d8a9..d23bc4ea 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -177,34 +177,60 @@ const toCreatePayload = (args: { }; }; +// Cache worktree listings to avoid repeated git worktree list + rev-parse calls +const _worktreeListCache = new Map(); +const _worktreeListInflight = new Map>(); +const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds + export async function listProjectWorktrees(project: ProjectRef): Promise { const projectDirectory = normalizePath(project.path); - const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); - const normalizedProjectDirectory = normalizePath(projectDirectory); - const worktrees = await git.worktree.list(projectDirectory).catch(() => []); - const results: WorktreeMetadata[] = worktrees - .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) - .map((entry) => { - const worktreePath = normalizePath(entry.path); - const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); - const name = (entry.name || '').trim(); - return { - source: 'sdk' as const, - name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), - path: worktreePath, - projectDirectory: metadataProjectDirectory, - branch, - label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), - }; - }) - .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); + // Return cached if fresh + const cached = _worktreeListCache.get(projectDirectory); + if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { + return cached.value; + } - return results.sort((a, b) => { - const aLabel = (a.label || a.branch || a.path).toLowerCase(); - const bLabel = (b.label || b.branch || b.path).toLowerCase(); - return aLabel.localeCompare(bLabel); + // Dedup in-flight requests + const inflight = _worktreeListInflight.get(projectDirectory); + if (inflight) return inflight; + + const promise = (async (): Promise => { + const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); + const normalizedProjectDirectory = normalizePath(projectDirectory); + + const worktrees = await git.worktree.list(projectDirectory).catch(() => []); + const results: WorktreeMetadata[] = worktrees + .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) + .map((entry) => { + const worktreePath = normalizePath(entry.path); + const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); + const name = (entry.name || '').trim(); + return { + source: 'sdk' as const, + name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), + path: worktreePath, + projectDirectory: metadataProjectDirectory, + branch, + label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), + }; + }) + .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); + + const sorted = results.sort((a, b) => { + const aLabel = (a.label || a.branch || a.path).toLowerCase(); + const bLabel = (b.label || b.branch || b.path).toLowerCase(); + return aLabel.localeCompare(bLabel); + }); + + _worktreeListCache.set(projectDirectory, { value: sorted, at: Date.now() }); + return sorted; + })().finally(() => { + _worktreeListInflight.delete(projectDirectory); }); + + _worktreeListInflight.set(projectDirectory, promise); + return promise; } export type CreateWorktreeArgs = { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 97445108..4be9544c 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -24,64 +24,15 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI throw new Error('Runtime APIs not provided for legacy UI entrypoint.'); })(); -await syncDesktopSettings(); -await initializeAppearancePreferences(); +await Promise.all([ + syncDesktopSettings(), + initializeAppearancePreferences(), + applyPersistedDirectoryPreferences(), +]); startAppearanceAutoSave(); startModelPrefsAutoSave(); startTypographyWatcher(); -await applyPersistedDirectoryPreferences(); -if (typeof window !== 'undefined') { - (window as { debugContextTokens?: () => void }).debugContextTokens = () => { - const sessionStore = (window as { __zustand_session_store__?: { getState: () => { currentSessionId?: string; messages: Map; sessionContextUsage: Map; getContextUsage: (contextLimit: number, outputLimit: number) => unknown } } }).__zustand_session_store__; - if (!sessionStore) { - return; - } - - const state = sessionStore.getState(); - const currentSessionId = state.currentSessionId; - - if (!currentSessionId) { - return; - } - - const sessionMessages = state.messages.get(currentSessionId) || []; - const assistantMessages = sessionMessages.filter((m: { info: { role: string } }) => m.info.role === 'assistant'); - - if (assistantMessages.length === 0) { - return; - } - - const lastMessage = assistantMessages[assistantMessages.length - 1]; - const tokens = (lastMessage.info as { tokens?: { input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } } }).tokens; - - if (tokens && typeof tokens === 'object') { - - console.debug('Token breakdown:', { - base: (tokens.input || 0) + (tokens.output || 0) + (tokens.reasoning || 0), - cache: tokens.cache ? (tokens.cache.read || 0) + (tokens.cache.write || 0) : 0 - }); - } - - void state.sessionContextUsage.get(currentSessionId); - - const configStore = (window as { __zustand_config_store__?: { getState: () => { getCurrentModel: () => { limit?: { context?: number } } | null } } }).__zustand_config_store__; - if (configStore) { - const currentModel = configStore.getState().getCurrentModel(); - const contextLimit = currentModel?.limit?.context || 0; - const outputLimit = - currentModel && currentModel.limit && typeof currentModel.limit === 'object' - ? Math.max(((currentModel.limit as { output?: number }).output ?? 0), 0) - : 0; - - if (contextLimit > 0) { - - void state.getContextUsage(contextLimit, outputLimit); - } - } - }; - -} const rootElement = document.getElementById('root'); if (!rootElement) { diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index b468621a..09c37383 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -47,8 +47,6 @@ interface ContextActions { saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void; getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined; - - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map) => Promise>; getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map) => ContextUsage | null; @@ -201,171 +199,6 @@ export const useContextStore = create()( return modelMap.get(`${providerId}/${modelId}`); }, - analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map) => { - const { saveAgentModelForSession, saveAgentModelVariantForSession } = get(); - - const agentLastChoices = new Map< - string, - { - providerId: string; - modelId: string; - timestamp: number; - } - >(); - - const extractAgentFromMessage = (messageInfo: any, messageIndex: number): string | null => { - - if ("mode" in messageInfo && messageInfo.mode && typeof messageInfo.mode === "string") { - const modeAgent = agents.find((a) => a.name === messageInfo.mode); - if (modeAgent) { - return messageInfo.mode; - } - } - - if ("agent" in messageInfo && messageInfo.agent && typeof messageInfo.agent === "string") { - const agent = agents.find((a) => a.name === messageInfo.agent); - if (agent) { - return messageInfo.agent; - } - } - - if (messageInfo.providerID && messageInfo.modelID) { - const matchingAgent = agents.find((agent) => agent.model?.providerID === messageInfo.providerID && agent.model?.modelID === messageInfo.modelID); - if (matchingAgent) { - return matchingAgent.name; - } - } - - const { currentAgentContext } = get(); - const contextAgent = currentAgentContext.get(sessionId); - if (contextAgent && agents.find((a) => a.name === contextAgent)) { - return contextAgent; - } - - if (messageIndex > 0 && messageInfo.providerID && messageInfo.modelID) { - - const sessionMessages = messages.get(sessionId) || []; - const assistantMessages = sessionMessages.filter((m) => m.info.role === "assistant").sort((a, b) => a.info.time.created - b.info.time.created); - - for (let i = messageIndex - 1; i >= 0; i--) { - const prevMessage = assistantMessages[i]; - const prevInfo = prevMessage.info as any; - if (prevInfo.providerID === messageInfo.providerID && prevInfo.modelID === messageInfo.modelID) { - - if (prevInfo.mode && typeof prevInfo.mode === "string") { - const prevModeAgent = agents.find((a) => a.name === prevInfo.mode); - if (prevModeAgent) { - return prevInfo.mode; - } - } - - const prevMatchingAgent = agents.find((agent) => agent.model?.providerID === prevInfo.providerID && agent.model?.modelID === prevInfo.modelID); - if (prevMatchingAgent) { - return prevMatchingAgent.name; - } - } - } - } - - if (messageInfo.providerID && messageInfo.modelID) { - const buildAgent = agents.find((a) => a.name === "build"); - if (buildAgent) { - return "build"; - } - } - - return null; - }; - - const sessionMessages = messages.get(sessionId) || []; - - const allMessages = sessionMessages.filter((m: any) => m.info.role === "assistant" || m.info.role === "user").sort((a: any, b: any) => a.info.time.created - b.info.time.created); - const assistantMessages = sessionMessages.filter((m: any) => m.info.role === "assistant").sort((a: any, b: any) => a.info.time.created - b.info.time.created); - - // Track variant from user messages to apply to corresponding assistant response - let pendingVariant: string | undefined = undefined; - let pendingUserModel: { providerID: string; modelID: string } | undefined = undefined; - - for (let messageIndex = 0; messageIndex < allMessages.length; messageIndex++) { - const message = allMessages[messageIndex]; - const { info } = message; - const infoAny = info as any; - - // User messages have variant and model info in different structure - if (infoAny.role === "user") { - const agentName = typeof infoAny.mode === 'string' && infoAny.mode.trim().length > 0 - ? infoAny.mode - : (typeof infoAny.agent === 'string' && infoAny.agent.trim().length > 0 ? infoAny.agent : undefined); - - const userProvider = typeof infoAny.model?.providerID === 'string' && infoAny.model.providerID.trim().length > 0 - ? infoAny.model.providerID - : (typeof infoAny.providerID === 'string' && infoAny.providerID.trim().length > 0 ? infoAny.providerID : undefined); - const userModel = typeof infoAny.model?.modelID === 'string' && infoAny.model.modelID.trim().length > 0 - ? infoAny.model.modelID - : (typeof infoAny.modelID === 'string' && infoAny.modelID.trim().length > 0 ? infoAny.modelID : undefined); - - const userVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0 - ? infoAny.variant - : undefined; - - if (agentName && userProvider && userModel && agents.find((a) => a.name === agentName)) { - const choice = { - providerId: userProvider, - modelId: userModel, - timestamp: info.time.created, - }; - const existing = agentLastChoices.get(agentName); - if (!existing || choice.timestamp > existing.timestamp) { - agentLastChoices.set(agentName, choice); - } - saveAgentModelVariantForSession(sessionId, agentName, userProvider, userModel, userVariant); - } - - // User message: variant is top-level, model is nested in model.providerID/modelID - pendingVariant = userVariant; - pendingUserModel = infoAny.model?.providerID && infoAny.model?.modelID - ? { providerID: infoAny.model.providerID, modelID: infoAny.model.modelID } - : undefined; - continue; - } - - // Assistant message: providerID/modelID are top-level - if (infoAny.providerID && infoAny.modelID) { - const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message)); - - if (agentName && agents.find((a) => a.name === agentName)) { - // Apply pending variant from user message if model matches - if (pendingUserModel && - pendingUserModel.providerID === infoAny.providerID && - pendingUserModel.modelID === infoAny.modelID) { - saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, pendingVariant); - } - - const choice = { - providerId: infoAny.providerID, - modelId: infoAny.modelID, - timestamp: info.time.created, - }; - - const existing = agentLastChoices.get(agentName); - if (!existing || choice.timestamp > existing.timestamp) { - agentLastChoices.set(agentName, choice); - } - } - } - - // Clear pending variant after processing assistant message - pendingVariant = undefined; - pendingUserModel = undefined; - } - - for (const [agentName, choice] of agentLastChoices) { - saveAgentModelForSession(sessionId, agentName, choice.providerId, choice.modelId); - } - - return agentLastChoices; - }, - getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map) => { if (!sessionId) return null; diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 59da696e..83ae9c9f 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -1,4 +1,5 @@ import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"; +import { retry } from "@/sync/retry"; export type GlobalSessionRecord = Session & { project?: { @@ -71,11 +72,14 @@ export async function listGlobalSessionPages( let cursor: number | undefined; while (true) { - const response = await apiClient.experimental.session.list({ - archived: options.archived, - limit: options.pageSize, - ...(cursor ? { cursor } : {}), - }); + const response = await retry( + () => apiClient.experimental.session.list({ + archived: options.archived, + limit: options.pageSize, + ...(cursor ? { cursor } : {}), + }), + { attempts: 3, delay: 500, retryIf: () => true }, + ); const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : []; if (payload.length === 0) { diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 7c22a80b..5e28dc01 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -9,6 +9,13 @@ export interface QueuedMessage { content: string; attachments?: AttachedFile[]; createdAt: number; + /** Send config captured at queue time — used as-is when auto-sending */ + sendConfig?: { + providerID: string; + modelID: string; + agent?: string; + variant?: string; + }; } interface MessageQueueState { @@ -42,6 +49,7 @@ export const useMessageQueueStore = create()( content: message.content, attachments: message.attachments, createdAt: Date.now(), + sendConfig: message.sendConfig, }; set((state) => { diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts deleted file mode 100644 index 87c04101..00000000 --- a/packages/ui/src/stores/messageStore.ts +++ /dev/null @@ -1,3006 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Message, Part } from "@opencode-ai/sdk/v2"; -import { opencodeClient } from "@/lib/opencode/client"; -import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; -import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from "@/lib/messages/providerAuthError"; -import type { SessionMemoryState, SessionHistoryMeta, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; -import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes"; -import { - touchStreamingLifecycle, - removeLifecycleEntries, - clearLifecycleTimersForIds, - clearLifecycleCompletionTimer -} from "./utils/streamingUtils"; -import { extractTextFromPart, normalizeStreamingPart } from "./utils/messageUtils"; -import { filterMessagesByRevertPoint, normalizeMessageInfoForProjection } from "./utils/messageProjectors"; -import { getSafeStorage } from "./utils/safeStorage"; -import { useFileStore } from "./fileStore"; -import { useSessionStore } from "./sessionStore"; -import { useContextStore } from "./contextStore"; -import { useUIStore } from "./useUIStore"; - -// Helper function to clean up pending user message metadata -const cleanupPendingUserMessageMeta = ( - currentPending: Map, - - sessionId: string -): Map => { - const nextPending = new Map(currentPending); - nextPending.delete(sessionId); - return nextPending; -}; - -const COMPACTION_WINDOW_MS = 30_000; - -const timeoutRegistry = new Map>(); -const lastContentRegistry = new Map(); -const streamingCooldownTimers = new Map>(); -const loadMessagesInFlightBySession = new Map>(); -const loadMessagesRequestSeqBySession = new Map(); - -interface QueuedStreamingPart { - sessionId: string; - messageId: string; - part: Part; - role?: string; - currentSessionId?: string; -} - -interface QueuedPartDelta { - sessionId: string; - messageId: string; - partId: string; - field: string; - delta: string; - role?: string; - currentSessionId?: string; -} - -type StreamingPartImmediateHandler = ( - sessionId: string, - messageId: string, - part: Part, - role?: string, - currentSessionId?: string, -) => void; - -const queuedNonTextStreamingPartsByKey = new Map(); -const queuedNonTextStreamingPartOrder: string[] = []; -let nonTextStreamingFlushScheduled = false; -let nonTextStreamingFlushRafId: number | null = null; -let nonTextStreamingFlushTimeoutId: ReturnType | null = null; -const NON_TEXT_STREAMING_QUEUE_HARD_LIMIT = 2500; - -const queuedPartDeltasByKey = new Map(); -const queuedPartDeltaOrder: string[] = []; -let partDeltaFlushScheduled = false; -let partDeltaFlushRafId: number | null = null; -let partDeltaFlushTimeoutId: ReturnType | null = null; -const PART_DELTA_QUEUE_HARD_LIMIT = 3500; -const ENABLE_STREAMING_FRAME_BATCHING = true; -const TOOL_STREAMING_BATCH_DELAY_MS = 120; - -const clearNonTextStreamingFlushSchedule = () => { - if (nonTextStreamingFlushRafId !== null) { - cancelAnimationFrame(nonTextStreamingFlushRafId); - nonTextStreamingFlushRafId = null; - } - if (nonTextStreamingFlushTimeoutId !== null) { - clearTimeout(nonTextStreamingFlushTimeoutId); - nonTextStreamingFlushTimeoutId = null; - } - nonTextStreamingFlushScheduled = false; -}; - -const queuedStreamingPartKey = (entry: QueuedStreamingPart): string => { - const partKey = getPartKey(entry.part) ?? `${entry.part.type ?? "unknown"}`; - const roleKey = typeof entry.role === 'string' ? entry.role : ''; - return `${entry.sessionId}:${entry.messageId}:${roleKey}:${partKey}`; -}; - -const enqueueNonTextStreamingPart = (entry: QueuedStreamingPart) => { - const key = queuedStreamingPartKey(entry); - if (!queuedNonTextStreamingPartsByKey.has(key)) { - queuedNonTextStreamingPartOrder.push(key); - } - queuedNonTextStreamingPartsByKey.set(key, entry); -}; - -const flushQueuedNonTextStreamingParts = ( - immediateHandler: StreamingPartImmediateHandler, - filter?: (entry: QueuedStreamingPart) => boolean, -) => { - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - return; - } - - const batch: QueuedStreamingPart[] = []; - const nextOrder: string[] = []; - - for (const key of queuedNonTextStreamingPartOrder) { - const entry = queuedNonTextStreamingPartsByKey.get(key); - if (!entry) { - continue; - } - if (filter && !filter(entry)) { - nextOrder.push(key); - continue; - } - batch.push(entry); - queuedNonTextStreamingPartsByKey.delete(key); - } - - queuedNonTextStreamingPartOrder.length = 0; - queuedNonTextStreamingPartOrder.push(...nextOrder); - - if (batch.length === 0) { - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } - return; - } - - for (const entry of batch) { - immediateHandler(entry.sessionId, entry.messageId, entry.part, entry.role, entry.currentSessionId); - } - - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } -}; - -const flushQueuedNonTextStreamingPartsForSession = ( - immediateHandler: StreamingPartImmediateHandler, - sessionId: string, -) => { - flushQueuedNonTextStreamingParts(immediateHandler, (entry) => entry.sessionId === sessionId); -}; - -const flushQueuedNonTextStreamingPartsForMessage = ( - immediateHandler: StreamingPartImmediateHandler, - sessionId: string, - messageId: string, -) => { - flushQueuedNonTextStreamingParts( - immediateHandler, - (entry) => entry.sessionId === sessionId && entry.messageId === messageId, - ); -}; - -const discardQueuedNonTextStreamingPartsForSession = (sessionId: string): void => { - if (queuedNonTextStreamingPartOrder.length === 0) { - return; - } - - for (let i = queuedNonTextStreamingPartOrder.length - 1; i >= 0; i -= 1) { - const key = queuedNonTextStreamingPartOrder[i]; - const entry = queuedNonTextStreamingPartsByKey.get(key); - if (!entry) { - queuedNonTextStreamingPartOrder.splice(i, 1); - continue; - } - if (entry.sessionId === sessionId) { - queuedNonTextStreamingPartsByKey.delete(key); - queuedNonTextStreamingPartOrder.splice(i, 1); - } - } - - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } -}; - -const scheduleNonTextStreamingFlush = (flush: () => void): void => { - if (nonTextStreamingFlushScheduled) { - return; - } - nonTextStreamingFlushScheduled = true; - nonTextStreamingFlushTimeoutId = setTimeout(() => { - nonTextStreamingFlushTimeoutId = null; - if (!nonTextStreamingFlushScheduled) { - return; - } - flush(); - }, TOOL_STREAMING_BATCH_DELAY_MS); -}; - -const clearPartDeltaFlushSchedule = () => { - if (partDeltaFlushRafId !== null) { - cancelAnimationFrame(partDeltaFlushRafId); - partDeltaFlushRafId = null; - } - if (partDeltaFlushTimeoutId !== null) { - clearTimeout(partDeltaFlushTimeoutId); - partDeltaFlushTimeoutId = null; - } - partDeltaFlushScheduled = false; -}; - -const queuedPartDeltaKey = (entry: QueuedPartDelta): string => { - const roleKey = typeof entry.role === 'string' ? entry.role : ''; - return `${entry.sessionId}:${entry.messageId}:${entry.partId}:${entry.field}:${roleKey}`; -}; - -const enqueuePartDelta = (entry: QueuedPartDelta) => { - const key = queuedPartDeltaKey(entry); - const existing = queuedPartDeltasByKey.get(key); - if (existing) { - existing.delta += entry.delta; - if (!existing.currentSessionId && entry.currentSessionId) { - existing.currentSessionId = entry.currentSessionId; - } - return; - } - - queuedPartDeltasByKey.set(key, { ...entry }); - queuedPartDeltaOrder.push(key); -}; - -const flushQueuedPartDeltas = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - filter?: (entry: QueuedPartDelta) => boolean, -) => { - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - return; - } - - const batch: QueuedPartDelta[] = []; - const nextOrder: string[] = []; - - for (const key of queuedPartDeltaOrder) { - const entry = queuedPartDeltasByKey.get(key); - if (!entry) { - continue; - } - if (filter && !filter(entry)) { - nextOrder.push(key); - continue; - } - batch.push(entry); - queuedPartDeltasByKey.delete(key); - } - - queuedPartDeltaOrder.length = 0; - queuedPartDeltaOrder.push(...nextOrder); - - if (batch.length === 0) { - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } - return; - } - - for (const entry of batch) { - immediateHandler( - entry.sessionId, - entry.messageId, - entry.partId, - entry.field, - entry.delta, - entry.role, - entry.currentSessionId, - ); - } - - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } -}; - -const flushQueuedPartDeltasForSession = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - sessionId: string, -) => { - flushQueuedPartDeltas(immediateHandler, (entry) => entry.sessionId === sessionId); -}; - -const flushQueuedPartDeltasForMessage = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - sessionId: string, - messageId: string, -) => { - flushQueuedPartDeltas( - immediateHandler, - (entry) => entry.sessionId === sessionId && entry.messageId === messageId, - ); -}; - -const discardQueuedPartDeltasForSession = (sessionId: string): void => { - if (queuedPartDeltaOrder.length === 0) { - return; - } - - for (let i = queuedPartDeltaOrder.length - 1; i >= 0; i -= 1) { - const key = queuedPartDeltaOrder[i]; - const entry = queuedPartDeltasByKey.get(key); - if (!entry) { - queuedPartDeltaOrder.splice(i, 1); - continue; - } - if (entry.sessionId === sessionId) { - queuedPartDeltasByKey.delete(key); - queuedPartDeltaOrder.splice(i, 1); - } - } - - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } -}; - -const schedulePartDeltaFlush = (flush: () => void): void => { - if (partDeltaFlushScheduled) { - return; - } - partDeltaFlushScheduled = true; - partDeltaFlushTimeoutId = setTimeout(() => { - partDeltaFlushTimeoutId = null; - if (!partDeltaFlushScheduled) { - return; - } - flush(); - }, TOOL_STREAMING_BATCH_DELAY_MS); -}; - -const shouldBatchStreamingPart = (part: Part | undefined): boolean => { - if (!part || typeof part.type !== 'string') { - return false; - } - - if (part.type === 'tool') { - return true; - } - - if (part.type === 'text' || part.type === 'reasoning') { - return useUIStore.getState().chatRenderMode === 'sorted'; - } - - return false; -}; - -const shouldBatchPartDelta = ( - messagesBySession: Map, - sessionId: string, - messageId: string, - partId: string, -): boolean => { - const sessionMessages = messagesBySession.get(sessionId); - if (!sessionMessages || sessionMessages.length === 0) { - return false; - } - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return false; - } - const targetMessage = sessionMessages[messageIndex]; - const targetPart = targetMessage.parts.find((part) => part?.id === partId); - if (targetPart?.type === 'tool') { - return true; - } - - if (targetPart?.type === 'text' || targetPart?.type === 'reasoning') { - return useUIStore.getState().chatRenderMode === 'sorted'; - } - - return false; -}; - -const RECENT_SEND_EMPTY_GUARD_MS = 15_000; - -const MIN_SORTABLE_LENGTH = 10; - -const extractSortableId = (id: unknown): string | null => { - if (typeof id !== "string") { - return null; - } - const trimmed = id.trim(); - if (!trimmed) { - return null; - } - const underscoreIndex = trimmed.indexOf("_"); - const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed; - if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) { - return null; - } - return candidate; -}; - -const countLoadedTurns = (messages: Array<{ info: { role?: string; clientRole?: string | null } }>): number => { - let count = 0; - for (const message of messages) { - const role = message.info.clientRole ?? message.info.role; - if (role === 'user') { - count += 1; - } - } - return count; -}; - -const compareMessageEntriesChronologically = ( - a: { info?: { id?: string; time?: { created?: number } } }, - b: { info?: { id?: string; time?: { created?: number } } }, -): number => { - const aId = typeof a?.info?.id === "string" ? a.info.id : ""; - const bId = typeof b?.info?.id === "string" ? b.info.id : ""; - - if (aId && bId) { - const aSortable = extractSortableId(aId); - const bSortable = extractSortableId(bId); - if (aSortable && bSortable && aSortable.length === bSortable.length && aSortable !== bSortable) { - return aSortable < bSortable ? -1 : 1; - } - if (aId !== bId) { - return aId.localeCompare(bId); - } - } - - const aCreated = typeof a?.info?.time?.created === "number" ? a.info.time.created : 0; - const bCreated = typeof b?.info?.time?.created === "number" ? b.info.time.created : 0; - return aCreated - bCreated; -}; - -const streamDebugEnabled = (): boolean => { - if (typeof window === "undefined") return false; - try { - return window.localStorage.getItem("openchamber_stream_debug") === "1"; - } catch { - return false; - } -}; - -const toFileUrl = (inputPath: string): string => { - const normalized = inputPath.replace(/\\/g, "/").trim(); - if (normalized.startsWith("file://")) { - return normalized; - } - const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; - return `file://${encodeURI(withLeadingSlash)}`; -}; - -const getPartKey = (part: Part | undefined): string | undefined => { - if (!part) { - return undefined; - } - if (typeof part.id === "string" && part.id.length > 0) { - return part.id; - } - if (part.type) { - const reason = (part as Record).reason; - const toolName = typeof (part as Record).tool === "string" - ? ((part as Record).tool as string) - : ""; - const directCallId = (part as Record).callID; - const nestedTool = (part as Record).tool; - const nestedCallId = - nestedTool && typeof nestedTool === "object" - ? (nestedTool as Record).callID - : undefined; - const callId = directCallId ?? nestedCallId; - return `${part.type}-${toolName}-${reason ?? ""}-${callId ?? ""}`; - } - return undefined; -}; - -const findMatchingPartIndex = (parts: Part[], incoming: Part): number => { - if (!Array.isArray(parts) || parts.length === 0) { - return -1; - } - - if (typeof incoming.id === "string" && incoming.id.length > 0) { - const byId = parts.findIndex((part) => part?.id === incoming.id); - if (byId !== -1) { - return byId; - } - - return -1; - } - - const incomingKey = getPartKey(incoming); - if (!incomingKey) { - return -1; - } - - return parts.findIndex((part) => getPartKey(part) === incomingKey); -}; - -const ignoredAssistantMessageIds = new Set(); - -const mergeDuplicateMessage = ( - existing: { info: any; parts: Part[] }, - incoming: { info: any; parts: Part[] } -): { info: any; parts: Part[] } => { - return { - ...incoming, - info: { - ...existing.info, - ...incoming.info, - }, - parts: Array.isArray(incoming.parts) ? incoming.parts : [], - }; -}; - -const dedupeMessagesById = (messages: { info: any; parts: Part[] }[]) => { - const deduped: { info: any; parts: Part[] }[] = []; - const indexById = new Map(); - - for (const message of messages) { - const messageId = typeof message?.info?.id === "string" ? message.info.id : null; - if (!messageId) { - deduped.push(message); - continue; - } - const existingIndex = indexById.get(messageId); - if (existingIndex === undefined) { - indexById.set(messageId, deduped.length); - deduped.push(message); - continue; - } - deduped[existingIndex] = mergeDuplicateMessage(deduped[existingIndex], message); - } - - return deduped; -}; - -const setStreamingIdForSession = (source: Map, sessionId: string, messageId: string | null) => { - const existing = source.get(sessionId); - if (existing === messageId) { - return source; - } - const next = new Map(source); - if (messageId) { - next.set(sessionId, messageId); - } else { - next.delete(sessionId); - } - return next; -}; - -const upsertMessageSessionIndex = (source: Map, messageId: string, sessionId: string) => { - const existing = source.get(messageId); - if (existing === sessionId) { - return source; - } - const next = new Map(source); - next.set(messageId, sessionId); - return next; -}; - -type StoredMessage = { info: any; parts: Part[] }; - -const sessionMessagePositionCache = new Map>(); - -const buildSessionMessagePositionIndex = (messages: StoredMessage[]): Map => { - const index = new Map(); - for (let position = 0; position < messages.length; position += 1) { - const id = (messages[position]?.info as { id?: unknown })?.id; - if (typeof id === 'string' && id.length > 0) { - index.set(id, position); - } - } - return index; -}; - -const primeSessionMessagePositionIndex = (sessionId: string, messages: StoredMessage[]) => { - sessionMessagePositionCache.set(sessionId, buildSessionMessagePositionIndex(messages)); -}; - -const updateSessionMessagePositionEntry = (sessionId: string, messageId: string, position: number) => { - let sessionIndex = sessionMessagePositionCache.get(sessionId); - if (!sessionIndex) { - sessionIndex = new Map(); - sessionMessagePositionCache.set(sessionId, sessionIndex); - } - sessionIndex.set(messageId, position); -}; - -const resolveSessionMessagePosition = (sessionId: string, messageId: string, messages: StoredMessage[]): number => { - const sessionIndex = sessionMessagePositionCache.get(sessionId); - const cachedPosition = sessionIndex?.get(messageId); - if ( - typeof cachedPosition === 'number' - && cachedPosition >= 0 - && cachedPosition < messages.length - && (messages[cachedPosition]?.info as { id?: unknown })?.id === messageId - ) { - return cachedPosition; - } - - const resolved = messages.findIndex((message) => message.info.id === messageId); - if (resolved !== -1) { - updateSessionMessagePositionEntry(sessionId, messageId, resolved); - } else if (sessionIndex) { - sessionIndex.delete(messageId); - } - - return resolved; -}; - -const removeMessageSessionIndexEntries = (source: Map, ids: Iterable) => { - const next = new Map(source); - let mutated = false; - for (const id of ids) { - if (next.delete(id)) { - mutated = true; - } - } - return mutated ? next : source; -}; - -const collectActiveMessageIdsForSession = (state: MessageState, sessionId: string): Set => { - const ids = new Set(); - const latest = state.streamingMessageIds.get(sessionId); - if (latest) { - ids.add(latest); - } - state.messageStreamStates.forEach((_lifecycle, messageId) => { - if (state.messageSessionIndex.get(messageId) === sessionId) { - ids.add(messageId); - } - }); - return ids; -}; - -const isMessageStreamingInSession = (state: MessageState, sessionId: string, messageId: string) => { - if (state.streamingMessageIds.get(sessionId) === messageId) { - return true; - } - return state.messageSessionIndex.get(messageId) === sessionId && state.messageStreamStates.has(messageId); -}; - -const resolveSessionDirectory = async (sessionId: string | null | undefined): Promise => { - if (!sessionId) { - return undefined; - } - - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - return directory ?? undefined; - } catch (error) { - console.warn('Failed to resolve session directory override:', error); - return undefined; - } -}; - -const getSessionRevertMessageId = (sessionId: string | null | undefined): string | null => { - if (!sessionId) return null; - try { - const sessionStore = useSessionStore.getState(); - const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { revert?: { messageID?: string } } | undefined; - return session?.revert?.messageID ?? null; - } catch { - return null; - } -}; - -const executeWithSessionDirectory = async (sessionId: string | null | undefined, operation: () => Promise): Promise => { - const directoryOverride = await resolveSessionDirectory(sessionId); - if (directoryOverride) { - return opencodeClient.withDirectory(directoryOverride, operation); - } - return operation(); -}; - -interface SessionAbortRecord { - timestamp: number; - acknowledged: boolean; -} - -interface MessageState { - messages: Map; - sessionMemoryState: Map; - sessionHistoryMeta: Map; - messageStreamStates: Map; - messageSessionIndex: Map; - streamingMessageIds: Map; - abortControllers: Map; - lastUsedProvider: { providerID: string; modelID: string } | null; - isSyncing: boolean; - pendingAssistantParts: Map; - sessionCompactionUntil: Map; - sessionAbortFlags: Map; - pendingUserMessageMetaBySession: Map; - -} - -interface MessageActions { - loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => Promise; - abortCurrentOperation: (currentSessionId?: string) => Promise; - _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; - _applyPartDeltaImmediate: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => void; - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => void; - forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source?: "timeout" | "cooldown") => void; - completeStreamingMessage: (sessionId: string, messageId: string) => void; - markMessageStreamSettled: (messageId: string) => void; - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => void; - updateViewportAnchor: (sessionId: string, anchor: number) => void; - loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; - getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; - updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => void; - acknowledgeSessionAbort: (sessionId: string) => void; -} - -type MessageStore = MessageState & MessageActions; - -export const useMessageStore = create()( - devtools( - persist( - (set, get) => ({ - - messages: new Map(), - sessionMemoryState: new Map(), - sessionHistoryMeta: new Map(), - messageStreamStates: new Map(), - messageSessionIndex: new Map(), - streamingMessageIds: new Map(), - abortControllers: new Map(), - lastUsedProvider: null, - isSyncing: false, - pendingAssistantParts: new Map(), - sessionCompactionUntil: new Map(), - sessionAbortFlags: new Map(), - pendingUserMessageMetaBySession: new Map(), - - loadMessages: async (sessionId: string, limit?: number) => { - const existingRequest = loadMessagesInFlightBySession.get(sessionId); - if (existingRequest) { - return existingRequest; - } - - const requestSeq = (loadMessagesRequestSeqBySession.get(sessionId) ?? 0) + 1; - loadMessagesRequestSeqBySession.set(sessionId, requestSeq); - const isLatestRequest = () => loadMessagesRequestSeqBySession.get(sessionId) === requestSeq; - - const task = (async () => { - const memLimits = getMemoryLimits(); - const noLimit = limit === Infinity; - const previousMemoryState = get().sessionMemoryState.get(sessionId); - const previousHistoryMeta = get().sessionHistoryMeta.get(sessionId); - if (previousHistoryMeta?.loading) { - return; - } - - // OpenCode parity: history window is driven by meta.limit. - const baseLimit = previousHistoryMeta?.limit ?? memLimits.HISTORICAL_MESSAGES; - const requestedLimit = - typeof limit === 'number' && Number.isFinite(limit) - ? limit - : baseLimit; - // Never proactively shrink loaded history window on resync. - const targetLimit = Math.max(baseLimit, requestedLimit); - - set((snapshot) => { - if (!isLatestRequest()) { - return snapshot; - } - const nextHistoryMeta = new Map(snapshot.sessionHistoryMeta); - const currentMeta = nextHistoryMeta.get(sessionId); - nextHistoryMeta.set(sessionId, { - limit: currentMeta?.limit ?? baseLimit, - complete: currentMeta?.complete ?? false, - loading: true, - }); - return { sessionHistoryMeta: nextHistoryMeta }; - }); - - // Don't pass Infinity to API - use undefined for "fetch all". - // Use targetLimit directly and infer "has more" when payload fills the window, - // matching OpenCode behavior and avoiding hidden "load older" on exact-limit responses. - try { - const fetchLimit = noLimit ? undefined : targetLimit; - const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit)); - if (!isLatestRequest()) { - return; - } - - // Filter out reverted messages first - const revertMessageId = getSessionRevertMessageId(sessionId); - const messagesWithoutReverted = filterMessagesByRevertPoint<{ info: Message; parts: Part[] }>( - allMessages as { info: Message; parts: Part[] }[], - revertMessageId, - ); - const orderedMessages = [...messagesWithoutReverted].sort(compareMessageEntriesChronologically); - - // If server fills the requested window, assume there may be more above. - const hasMoreAbove = typeof fetchLimit === 'number' - ? orderedMessages.length >= targetLimit - : false; - - const messagesToKeep = orderedMessages.slice(-targetLimit); - - set((state) => { - if (!isLatestRequest()) { - return state; - } - - const previousMessages = state.messages.get(sessionId) || []; - const normalizedMessages = messagesToKeep.map((message) => { - const infoWithMarker = normalizeMessageInfoForProjection(message.info as Message) as any; - - const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { - if (part?.type === 'text') { - const raw = (part as any).text ?? (part as any).content ?? ''; - if (isExecutionForkMetaText(raw)) { - return { ...part, synthetic: true } as Part; - } - } - return part; - }); - return { - ...message, - info: infoWithMarker, - parts: serverParts, - }; - }); - - const mergedMessages = dedupeMessagesById(normalizedMessages); - const currentMemoryState = state.sessionMemoryState.get(sessionId) ?? previousMemoryState; - const hasStreamingMessage = Boolean(state.streamingMessageIds.get(sessionId)); - const sentRecently = - typeof currentMemoryState?.lastUserMessageAt === 'number' - && Date.now() - currentMemoryState.lastUserMessageAt < RECENT_SEND_EMPTY_GUARD_MS; - - const shouldPreserveExistingSnapshot = - mergedMessages.length === 0 - && previousMessages.length > 0 - && (hasStreamingMessage || currentMemoryState?.isStreaming === true || sentRecently); - - if (shouldPreserveExistingSnapshot) { - const newMemoryState = new Map(state.sessionMemoryState); - const existingMemory = newMemoryState.get(sessionId) ?? previousMemoryState ?? { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - newMemoryState.set(sessionId, { - ...existingMemory, - lastAccessedAt: Date.now(), - historyLoading: false, - historyLimit: targetLimit, - }); - - const newHistoryMeta = new Map(state.sessionHistoryMeta); - const currentMeta = newHistoryMeta.get(sessionId); - newHistoryMeta.set(sessionId, { - limit: targetLimit, - complete: currentMeta?.complete ?? false, - loading: false, - }); - - return { - sessionMemoryState: newMemoryState, - sessionHistoryMeta: newHistoryMeta, - }; - } - - const loadedTurnCount = countLoadedTurns(mergedMessages); - const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); - const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); - const removedIds: string[] = []; - previousIds.forEach((id) => { - if (!nextIds.has(id)) { - removedIds.push(id); - } - }); - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, mergedMessages); - primeSessionMessagePositionIndex(sessionId, mergedMessages); - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...previousMemoryState, - viewportAnchor: mergedMessages.length - 1, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - totalAvailableMessages: previousMemoryState?.totalAvailableMessages, - loadedTurnCount, - hasMoreAbove, - hasMoreTurnsAbove: hasMoreAbove, - historyLoading: false, - historyComplete: !hasMoreAbove, - historyLimit: targetLimit, - streamingCooldownUntil: undefined, - }); - - const newHistoryMeta = new Map(state.sessionHistoryMeta); - newHistoryMeta.set(sessionId, { - limit: targetLimit, - complete: !hasMoreAbove, - loading: false, - }); - - const result: Record = { - messages: newMessages, - sessionMemoryState: newMemoryState, - sessionHistoryMeta: newHistoryMeta, - }; - - clearLifecycleTimersForIds(removedIds); - const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); - if (updatedLifecycle !== state.messageStreamStates) { - result.messageStreamStates = updatedLifecycle; - } - - if (removedIds.length > 0) { - const currentStreaming = state.streamingMessageIds.get(sessionId); - if (currentStreaming && removedIds.includes(currentStreaming)) { - result.streamingMessageIds = setStreamingIdForSession( - result.streamingMessageIds ?? state.streamingMessageIds, - sessionId, - null - ); - } - } - - if (removedIds.length > 0) { - const nextIndex = removeMessageSessionIndexEntries( - result.messageSessionIndex ?? state.messageSessionIndex, - removedIds - ); - if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { - result.messageSessionIndex = nextIndex; - } - } - - if (removedIds.length > 0) { - const nextPendingParts = new Map(state.pendingAssistantParts); - let pendingChanged = false; - removedIds.forEach((id) => { - if (nextPendingParts.delete(id)) { - pendingChanged = true; - } - }); - if (pendingChanged) { - result.pendingAssistantParts = nextPendingParts; - } - } - - const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; - let indexAccumulator = targetIndex; - mergedMessages.forEach((message) => { - const id = (message?.info as { id?: unknown })?.id; - if (typeof id === "string" && id.length > 0) { - indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); - } - }); - if (indexAccumulator !== targetIndex) { - result.messageSessionIndex = indexAccumulator; - } - - return result; - }); - } finally { - set((snapshot) => { - if (!isLatestRequest()) { - return snapshot; - } - const currentMeta = snapshot.sessionHistoryMeta.get(sessionId); - if (!currentMeta?.loading) { - return snapshot; - } - const nextHistoryMeta = new Map(snapshot.sessionHistoryMeta); - nextHistoryMeta.set(sessionId, { - ...currentMeta, - loading: false, - }); - return { sessionHistoryMeta: nextHistoryMeta }; - }); - } - })(); - - loadMessagesInFlightBySession.set(sessionId, task); - try { - await task; - } finally { - if (loadMessagesInFlightBySession.get(sessionId) === task) { - loadMessagesInFlightBySession.delete(sessionId); - } - } - }, - - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => { - if (!currentSessionId) { - throw new Error("No session selected"); - } - - const sessionId = currentSessionId; - - if (get().sessionAbortFlags.has(sessionId)) { - set((state) => { - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.delete(sessionId); - return { sessionAbortFlags: nextAbortFlags }; - }); - } - - await executeWithSessionDirectory(sessionId, async () => { - try { - const trimmedContent = content.trimStart(); - const firstTokenLooksLikeAbsolutePath = (() => { - if (!trimmedContent.startsWith('/')) return false; - const firstWhitespaceIndex = trimmedContent.search(/\s/); - const firstToken = firstWhitespaceIndex === -1 - ? trimmedContent - : trimmedContent.slice(0, firstWhitespaceIndex); - if (firstToken.length <= 1) return false; - const tokenWithoutLeadingSlash = firstToken.slice(1); - if (!tokenWithoutLeadingSlash.includes('/')) return false; - return true; - })(); - const commandPayload = (() => { - if (inputMode === 'shell') return null; - if (!trimmedContent.startsWith("/")) return null; - if (firstTokenLooksLikeAbsolutePath) return null; - const firstLineEnd = trimmedContent.indexOf("\n"); - const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); - const [commandToken, ...firstLineArgs] = firstLine.split(" "); - const command = commandToken.slice(1).trim(); - if (command.toLowerCase() === "shell") return null; - if (!command) return null; - const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); - const argsFromFirstLine = firstLineArgs.join(" ").trim(); - const args = restOfInput - ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) - : argsFromFirstLine; - return { - command, - arguments: args, - }; - })(); - const shellPayload = (() => { - if (inputMode !== 'shell') return null; - const command = content.trim(); - if (!command.trim()) return null; - return { command }; - })(); - const slashShellPayload = (() => { - if (!trimmedContent.startsWith("/")) return null; - if (firstTokenLooksLikeAbsolutePath) return null; - const firstLineEnd = trimmedContent.indexOf("\n"); - const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); - const [commandToken, ...firstLineArgs] = firstLine.split(" "); - const commandName = commandToken.slice(1).trim().toLowerCase(); - if (commandName !== "shell") return null; - const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); - const argsFromFirstLine = firstLineArgs.join(" ").trim(); - const command = restOfInput - ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) - : argsFromFirstLine; - if (!command.trim()) return null; - return { command }; - })(); - - set({ - lastUsedProvider: { providerID, modelID }, - }); - - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId) || { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - - const existingTimer = streamingCooldownTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - streamingCooldownTimers.delete(sessionId); - } - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryState, - isStreaming: true, - streamStartTime: Date.now(), - streamingCooldownUntil: undefined, - }); - return { sessionMemoryState: newMemoryState }; - }); - - try { - const controller = new AbortController(); - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.set(sessionId, controller); - return { abortControllers: nextControllers }; - }); - - const filePayloads = (attachments ?? []).map((file) => ({ - type: "file" as const, - mime: file.mimeType, - filename: file.filename, - url: - file.source === "server" && - file.serverPath && - (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") - ? toFileUrl(file.serverPath) - : file.dataUrl, - })); - - set((state) => { - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.set(sessionId, { - mode: typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined, - providerID, - modelID, - variant: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined, - }); - return { pendingUserMessageMetaBySession: nextUserMeta }; - }); - - // Convert additional parts to SDK format - const additionalPartsPayload = additionalParts?.map((part) => ({ - text: part.text, - synthetic: part.synthetic, - files: part.attachments?.map((file) => ({ - type: "file" as const, - mime: file.mimeType, - filename: file.filename, - url: - file.source === "server" && - file.serverPath && - (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") - ? toFileUrl(file.serverPath) - : file.dataUrl, - })), - })); - - const apiClient = opencodeClient.getApiClient(); - const directory = opencodeClient.getDirectory(); - - if (shellPayload || slashShellPayload) { - await apiClient.session.shell({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - ...(agent ? { agent } : {}), - model: { - providerID, - modelID, - }, - command: (shellPayload ?? slashShellPayload)!.command, - }); - } else if (commandPayload && commandPayload.command.toLowerCase() === 'compact') { - await apiClient.session.summarize({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - providerID, - modelID, - }); - } else if (commandPayload) { - await opencodeClient.sendCommand({ - id: sessionId, - providerID, - modelID, - command: commandPayload.command, - arguments: commandPayload.arguments, - agent, - variant, - files: filePayloads.length > 0 ? filePayloads : undefined, - }); - } else { - if (format) { - console.info('[git-generation][browser] dispatch structured sendMessage', { - sessionId, - providerID, - modelID, - agent, - variant, - directory, - formatType: format.type, - }); - } - await opencodeClient.sendMessage({ - id: sessionId, - providerID, - modelID, - text: content, - agent, - variant, - ...(format ? { format } : {}), - files: filePayloads.length > 0 ? filePayloads : undefined, - additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, - agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, - }); - } - - if (filePayloads.length > 0) { - try { - useFileStore.getState().clearAttachedFiles(); - } catch (clearError) { - console.error("Failed to clear attached files after send", clearError); - } - } - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - return { abortControllers: nextControllers }; - }); - } catch (error: any) { - let errorMessage = "Network error while sending message. The message may still be processing."; - - if (error.name === "AbortError") { - errorMessage = "Request timed out. The message may still be processing."; - } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { - errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - return { abortControllers: nextControllers }; - }); - return; - } else if (isLikelyProviderAuthFailure(error.message)) { - errorMessage = PROVIDER_AUTH_FAILURE_MESSAGE; - } else if (error.message) { - errorMessage = error.message; - } - - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.delete(sessionId); - return { abortControllers: nextControllers, pendingUserMessageMetaBySession: nextUserMeta }; - }); - - throw new Error(errorMessage); - } - } catch (error: any) { - let errorMessage = "Network error while sending message. The message may still be processing."; - - if (error.name === "AbortError") { - errorMessage = "Request timed out. The message may still be processing."; - } else if (error.response?.status === 401) { - errorMessage = "Session not found or unauthorized. Please refresh the page."; - } else if (error.response?.status === 502) { - errorMessage = "OpenCode is restarting. Please wait a moment and try again."; - } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { - errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; - } else if (isLikelyProviderAuthFailure(error.message)) { - errorMessage = PROVIDER_AUTH_FAILURE_MESSAGE; - } else if (error.message) { - errorMessage = error.message; - } - - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.delete(sessionId); - return { abortControllers: nextControllers, pendingUserMessageMetaBySession: nextUserMeta }; - }); - - throw new Error(errorMessage); - } - }); - }, - - abortCurrentOperation: async (currentSessionId?: string) => { - if (!currentSessionId) { - return; - } - - discardQueuedNonTextStreamingPartsForSession(currentSessionId); - discardQueuedPartDeltasForSession(currentSessionId); - - const stateSnapshot = get(); - const { abortControllers, messages: storeMessages } = stateSnapshot; - - const controller = abortControllers.get(currentSessionId); - controller?.abort(); - - const activeIds = collectActiveMessageIdsForSession(stateSnapshot, currentSessionId); - - if (activeIds.size === 0) { - const sessionMessages = currentSessionId ? storeMessages.get(currentSessionId) ?? [] : []; - let fallbackAssistantId: string | null = null; - for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { - const message = sessionMessages[index]; - if (!message || message.info.role !== 'assistant') { - continue; - } - - if (!fallbackAssistantId) { - fallbackAssistantId = message.info.id; - } - - const hasWorkingPart = (message.parts ?? []).some((part) => { - return part.type === 'reasoning' || part.type === 'tool' || part.type === 'step-start'; - }); - if (hasWorkingPart) { - activeIds.add(message.info.id); - break; - } - } - - if (activeIds.size === 0 && fallbackAssistantId) { - activeIds.add(fallbackAssistantId); - } - } - - for (const id of activeIds) { - const timeout = timeoutRegistry.get(id); - if (timeout) { - clearTimeout(timeout); - timeoutRegistry.delete(id); - lastContentRegistry.delete(id); - } - } - - if (activeIds.size > 0) { - clearLifecycleTimersForIds(activeIds); - } - - const abortTimestamp = Date.now(); - - set((state) => { - const updatedStates = removeLifecycleEntries(state.messageStreamStates, activeIds); - - const sessionMessages = state.messages.get(currentSessionId) ?? []; - let messagesChanged = false; - let updatedMessages = state.messages; - - if (sessionMessages.length > 0 && activeIds.size > 0) { - const updatedSessionMessages = sessionMessages.map((message) => { - if (!activeIds.has(message.info.id) && activeIds.size > 0) { - return message; - } - - const updatedParts = (message.parts ?? []).map((part) => { - if (part.type === 'reasoning') { - const reasoningPart = part as any; - const time = { ...(reasoningPart.time ?? {}) }; - if (typeof time.end !== 'number') { - time.end = abortTimestamp; - } - return { - ...reasoningPart, - time, - } as Part; - } - - if (part.type === 'tool') { - const toolPart = part as any; - const stateData = { ...(toolPart.state ?? {}) }; - if (stateData.status === 'running' || stateData.status === 'pending') { - stateData.status = 'aborted'; - } - return { - ...toolPart, - state: stateData, - } as Part; - } - - if (part.type === 'step-start') { - const stepPart = part as any; - return { - ...stepPart, - type: 'step-finish', - aborted: true, - } as Part; - } - - return part; - }); - - messagesChanged = true; - return { - ...message, - info: { - ...message.info, - abortedAt: abortTimestamp, - streaming: false, - status: 'aborted', - }, - parts: updatedParts, - }; - }); - - if (messagesChanged) { - updatedMessages = new Map(state.messages); - updatedMessages.set(currentSessionId, updatedSessionMessages); - } - } - const memoryState = state.sessionMemoryState.get(currentSessionId); - let nextMemoryState = state.sessionMemoryState; - if (memoryState) { - const updatedMemory = new Map(state.sessionMemoryState); - updatedMemory.set(currentSessionId, { - ...memoryState, - isStreaming: false, - streamStartTime: undefined, - isZombie: false, - }); - nextMemoryState = updatedMemory; - } - - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.set(currentSessionId, { - timestamp: abortTimestamp, - acknowledged: false, - }); - - return { - messageStreamStates: updatedStates, - sessionMemoryState: nextMemoryState, - sessionAbortFlags: nextAbortFlags, - abortControllers: (() => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(currentSessionId); - return nextControllers; - })(), - streamingMessageIds: setStreamingIdForSession(state.streamingMessageIds, currentSessionId, null), - ...(messagesChanged ? { messages: updatedMessages } : {}), - }; - }); - - void opencodeClient.abortSession(currentSessionId).catch((error) => { - console.warn('Abort request failed:', error); - }); - }, - - _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { - const stateSnapshot = get(); - if (ignoredAssistantMessageIds.has(messageId)) { - return; - } - - const existingMessagesSnapshot = stateSnapshot.messages.get(sessionId) || []; - const existingMessageSnapshotIndex = resolveSessionMessagePosition(sessionId, messageId, existingMessagesSnapshot); - const existingMessageSnapshot = existingMessageSnapshotIndex >= 0 - ? existingMessagesSnapshot[existingMessageSnapshotIndex] - : undefined; - - const actualRole = (() => { - if (role === 'user') return 'user'; - if (existingMessageSnapshot?.info.role === 'user') return 'user'; - return role || existingMessageSnapshot?.info.role || 'assistant'; - })(); - - const memoryStateSnapshot = get().sessionMemoryState.get(sessionId); - if (memoryStateSnapshot?.streamStartTime) { - const streamDuration = Date.now() - memoryStateSnapshot.streamStartTime; - if (streamDuration > MEMORY_LIMITS.ZOMBIE_TIMEOUT) { - if (!memoryStateSnapshot.isZombie) { - set((state) => { - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryStateSnapshot, - isZombie: true, - }); - return { sessionMemoryState: newMemoryState }; - }); - } - - setTimeout(() => { - const store = get(); - store.completeStreamingMessage(sessionId, messageId); - }, 0); - (window as any).__messageTracker?.(messageId, 'skipped_zombie_stream'); - return; - } - } - - set((state) => { - const sessionMessages = state.messages.get(sessionId) || []; - const messagesArray = [...sessionMessages]; - const updates: any = {}; - - const indexedSessions = upsertMessageSessionIndex(state.messageSessionIndex, messageId, sessionId); - if (indexedSessions !== state.messageSessionIndex) { - updates.messageSessionIndex = indexedSessions; - } - - const finalizeAbortState = (result: Partial): Partial => { - const shouldClearAbortFlag = - (actualRole === 'assistant' || actualRole === 'user') && - state.sessionAbortFlags.has(sessionId); - if (!shouldClearAbortFlag) { - return result; - } - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.delete(sessionId); - return { - ...result, - sessionAbortFlags: nextAbortFlags, - }; - }; - - const maintainTimeouts = (text: string) => { - const value = text || ''; - const lastContent = lastContentRegistry.get(messageId); - - if (value && lastContent === value) { - const currentState = get(); - if (isMessageStreamingInSession(currentState, sessionId, messageId)) { - const existingTimeout = timeoutRegistry.get(messageId); - if (existingTimeout) { - clearTimeout(existingTimeout); - timeoutRegistry.delete(messageId); - } - setTimeout(() => { - const store = get(); - if (typeof store.forceCompleteMessage === "function") { - store.forceCompleteMessage(sessionId, messageId, "timeout"); - } - store.completeStreamingMessage(sessionId, messageId); - }, 100); - } - } - - lastContentRegistry.set(messageId, value); - - const existingTimeout = timeoutRegistry.get(messageId); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - const newTimeout = setTimeout(() => { - const store = get(); - if (typeof store.forceCompleteMessage === "function") { - store.forceCompleteMessage(sessionId, messageId, "timeout"); - } - if (isMessageStreamingInSession(store, sessionId, messageId)) { - store.completeStreamingMessage(sessionId, messageId); - } - timeoutRegistry.delete(messageId); - lastContentRegistry.delete(messageId); - }, 8000); - timeoutRegistry.set(messageId, newTimeout); - }; - - const isBackgroundSession = sessionId !== currentSessionId; - const memoryState = state.sessionMemoryState.get(sessionId); - if (isBackgroundSession && memoryState?.isStreaming) { - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryState, - backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1, - }); - updates.sessionMemoryState = newMemoryState; - } - - if (actualRole === 'assistant') { - const baseMemoryMap = updates.sessionMemoryState ?? state.sessionMemoryState; - const currentMemoryState = baseMemoryMap.get(sessionId); - if (currentMemoryState) { - const now = Date.now(); - const nextMemoryState = new Map(baseMemoryMap); - nextMemoryState.set(sessionId, { - ...currentMemoryState, - isStreaming: true, - streamStartTime: currentMemoryState.streamStartTime ?? now, - lastAccessedAt: now, - isZombie: false, - }); - updates.sessionMemoryState = nextMemoryState; - } - } - - const incomingText = extractTextFromPart(part); - if (isExecutionForkMetaText(incomingText)) { - (part as any).synthetic = true; - } - if (streamDebugEnabled() && actualRole === "assistant") { - try { - console.info("[STREAM-TRACE] part", { - messageId, - partId: (part as any)?.id, - role: actualRole, - type: (part as any)?.type || "text", - textLen: incomingText.length, - snapshotParts: existingMessagesSnapshot.length, - }); - } catch { /* ignored */ } - } - - const previousStreamingMap = updates.streamingMessageIds ?? state.streamingMessageIds; - if (actualRole === 'assistant') { - const nextStreamingMap = setStreamingIdForSession(previousStreamingMap, sessionId, messageId); - if (nextStreamingMap !== previousStreamingMap) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set_latest'); - } - } - - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, messagesArray); - - if (messageIndex !== -1 && actualRole === 'user') { - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - - if ((part as any).synthetic === true) { - const incomingText = extractTextFromPart(part).trim(); - const shouldKeep = - incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at ') || - incomingText.startsWith('The following tool was executed by the user'); - if (!shouldKeep) { - (window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part'); - return state; - } - } - - const normalizedPart = normalizeStreamingPart( - part, - existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined - ); - (window as any).__messageTracker?.(messageId, `user_part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if (actualRole === 'assistant' && messageIndex !== -1) { - - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - - const normalizedPart = normalizeStreamingPart( - part, - existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined - ); - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if (messageIndex === -1) { - - if (actualRole === 'user') { - - if ((part as any).synthetic === true) { - const incomingText = extractTextFromPart(part).trim(); - const shouldKeep = - incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at ') || - incomingText.startsWith('The following tool was executed by the user'); - if (!shouldKeep) { - (window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part'); - return state; - } - } - - const normalizedPart = normalizeStreamingPart(part); - (window as any).__messageTracker?.(messageId, `new_user_part_type:${(normalizedPart as any).type || 'unknown'}`); - - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - const contextStore = useContextStore.getState(); - const sessionAgent = - pendingMeta?.mode ?? - contextStore.getSessionAgentSelection(sessionId) ?? - contextStore.getCurrentAgent(sessionId); - const agentMode = typeof sessionAgent === 'string' && sessionAgent.trim().length > 0 - ? sessionAgent.trim() - : undefined; - const providerID = pendingMeta?.providerID ?? (state.lastUsedProvider?.providerID || undefined); - const modelID = pendingMeta?.modelID ?? (state.lastUsedProvider?.modelID || undefined); - - if (pendingMeta) { - updates.pendingUserMessageMetaBySession = cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId); - } - - const newUserMessage = { - info: { - id: messageId, - sessionID: sessionId, - role: 'user' as const, - clientRole: 'user', - userMessageMarker: true, - ...(agentMode ? { mode: agentMode } : {}), - ...(providerID ? { providerID } : {}), - ...(modelID ? { modelID } : {}), - time: { - created: Date.now(), - }, - }, - parts: [normalizedPart], - }; - - const updatedMessages = [...messagesArray, newUserMessage]; - - updatedMessages.sort((a, b) => { - const aTime = (a.info as any)?.time?.created || 0; - const bTime = (b.info as any)?.time?.created || 0; - return aTime - bTime; - }); - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - primeSessionMessagePositionIndex(sessionId, updatedMessages); - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if ((part as any)?.type === 'text') { - const textIncoming = extractTextFromPart(part).trim(); - if (textIncoming.length > 0) { - const latestUser = [...messagesArray] - .reverse() - .find((m) => m.info.role === 'user'); - if (latestUser) { - const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim(); - if (latestUserText.length > 0 && latestUserText === textIncoming) { - // Cap ignoredAssistantMessageIds size — it's only relevant for active streaming - if (ignoredAssistantMessageIds.size > 1000) { - ignoredAssistantMessageIds.clear(); - } - ignoredAssistantMessageIds.add(messageId); - (window as any).__messageTracker?.(messageId, 'ignored_assistant_echo'); - return state; - } - } - } - } - - const pendingEntry = state.pendingAssistantParts.get(messageId); - const pendingParts = pendingEntry ? [...pendingEntry.parts] : []; - const pendingIndex = findMatchingPartIndex(pendingParts, part); - const existingPendingPart = pendingIndex !== -1 ? pendingParts[pendingIndex] : undefined; - const normalizedPart = normalizeStreamingPart(part, existingPendingPart); - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - if (pendingIndex !== -1) { - const normalizedRecord = normalizedPart as Record; - if ( - normalizedRecord.type === 'tool' && - typeof existingPendingPart?.id === 'string' && - existingPendingPart.id.length > 0 - ) { - normalizedRecord.id = existingPendingPart.id; - } - pendingParts[pendingIndex] = normalizedRecord as Part; - } else { - pendingParts.push(normalizedPart); - } - - const newPending = new Map(state.pendingAssistantParts); - newPending.set(messageId, { sessionId, parts: pendingParts }); - - const providerID = state.lastUsedProvider?.providerID || ""; - const modelID = state.lastUsedProvider?.modelID || ""; - const now = Date.now(); - const cwd = opencodeClient.getDirectory() ?? "/"; - const contextStore = useContextStore.getState(); - const sessionAgent = contextStore.getSessionAgentSelection(sessionId) - ?? contextStore.getCurrentAgent(sessionId); - const agentMode = typeof sessionAgent === "string" && sessionAgent.trim().length > 0 - ? sessionAgent.trim() - : undefined; - - const placeholderInfo = (actualRole === "user" - ? { - id: messageId, - sessionID: sessionId, - role: "user", - time: { created: now }, - agent: agentMode || "default", - model: { providerID, modelID }, - clientRole: actualRole, - animationSettled: undefined, - streaming: undefined, - } - : { - id: messageId, - sessionID: sessionId, - role: "assistant", - time: { created: now }, - parentID: messageId, - modelID, - providerID, - mode: agentMode || "default", - path: { cwd, root: cwd }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - clientRole: actualRole, - animationSettled: false, - streaming: true, - }) as unknown as Message; - - const placeholderMessage = { - info: placeholderInfo, - parts: pendingParts, - }; - - const nextMessages = [...messagesArray, placeholderMessage]; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, nextMessages); - updateSessionMessagePositionEntry(sessionId, messageId, nextMessages.length - 1); - - if (actualRole === 'assistant') { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - } - - return finalizeAbortState({ - messages: newMessages, - pendingAssistantParts: newPending, - ...updates, - }); - } else { - - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - const existingPart = existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined; - - const normalizedPart = normalizeStreamingPart( - part, - existingPart - ); - if ( - (normalizedPart as Record).type === 'tool' && - typeof existingPart?.id === 'string' && - existingPart.id.length > 0 - ) { - (normalizedPart as Record).id = existingPart.id; - } - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - if (updatedMessage.info.role === "assistant") { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - } - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - }); - }, - - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { - if (!ENABLE_STREAMING_FRAME_BATCHING) { - get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId); - return; - } - - if (!shouldBatchStreamingPart(part)) { - get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId); - return; - } - - enqueueNonTextStreamingPart({ - sessionId, - messageId, - part, - role, - currentSessionId, - }); - - const flushQueuedParts = () => { - flushQueuedNonTextStreamingParts(get()._addStreamingPartImmediate); - }; - - if (queuedNonTextStreamingPartOrder.length >= NON_TEXT_STREAMING_QUEUE_HARD_LIMIT) { - flushQueuedParts(); - return; - } - - scheduleNonTextStreamingFlush(flushQueuedParts); - }, - - _applyPartDeltaImmediate: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => { - set((state) => { - const sessionMessages = state.messages.get(sessionId) || []; - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return state; - } - - const targetMessage = sessionMessages[messageIndex]; - const partIndex = targetMessage.parts.findIndex((part) => part?.id === partId); - if (partIndex === -1) { - return state; - } - - const existingPart = targetMessage.parts[partIndex] as Record; - const existingField = existingPart[field]; - - const nextFieldValue = `${typeof existingField === 'string' ? existingField : ''}${delta}`; - const nextPart: Record = { - ...existingPart, - [field]: nextFieldValue, - }; - - const updatedParts = [...targetMessage.parts]; - updatedParts[partIndex] = nextPart as Part; - - const updatedMessage = { - ...targetMessage, - parts: updatedParts, - }; - - const updatedSessionMessages = [...sessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - - const nextMessages = new Map(state.messages); - nextMessages.set(sessionId, updatedSessionMessages); - - const actualRole = (() => { - if (role === 'user') return 'user'; - if (updatedMessage.info.role === 'user') return 'user'; - return role || updatedMessage.info.role || 'assistant'; - })(); - - const updates: Partial = { - messages: nextMessages, - }; - - if (streamDebugEnabled() && actualRole === 'assistant') { - try { - const previousText = extractTextFromPart(existingPart as Part); - const nextText = extractTextFromPart(nextPart as Part); - console.info('[STREAM-TRACE] delta_apply', { - messageId, - partId, - field, - deltaLen: delta.length, - prevFieldLen: typeof existingField === 'string' ? existingField.length : 0, - nextFieldLen: nextFieldValue.length, - prevTextLen: previousText.length, - nextTextLen: nextText.length, - partType: typeof existingPart.type === 'string' ? existingPart.type : 'unknown', - }); - } catch { - // ignore debug log failures - } - } - - if (actualRole === 'assistant') { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const effectiveCurrent = currentSessionId || sessionId; - const nextStreamingMap = setStreamingIdForSession(state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== state.streamingMessageIds) { - updates.streamingMessageIds = nextStreamingMap; - } - - if (effectiveCurrent !== sessionId) { - const memoryState = state.sessionMemoryState.get(sessionId); - if (memoryState) { - const now = Date.now(); - const nextMemoryState = new Map(state.sessionMemoryState); - nextMemoryState.set(sessionId, { - ...memoryState, - isStreaming: true, - streamStartTime: memoryState.streamStartTime ?? now, - lastAccessedAt: now, - isZombie: false, - }); - updates.sessionMemoryState = nextMemoryState; - } - } - } - - return updates; - }); - }, - - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => { - if (!ENABLE_STREAMING_FRAME_BATCHING) { - get()._applyPartDeltaImmediate(sessionId, messageId, partId, field, delta, role, currentSessionId); - return; - } - - if (!shouldBatchPartDelta(get().messages, sessionId, messageId, partId)) { - get()._applyPartDeltaImmediate(sessionId, messageId, partId, field, delta, role, currentSessionId); - return; - } - - enqueuePartDelta({ - sessionId, - messageId, - partId, - field, - delta, - role, - currentSessionId, - }); - - const flushQueuedDeltas = () => { - flushQueuedPartDeltas(get()._applyPartDeltaImmediate); - }; - - if (queuedPartDeltaOrder.length >= PART_DELTA_QUEUE_HARD_LIMIT) { - flushQueuedDeltas(); - return; - } - - schedulePartDeltaFlush(flushQueuedDeltas); - }, - - forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => { - const resolveSessionId = (state: MessageState): string | null => { - if (sessionId) { - return sessionId; - } - const indexedSession = state.messageSessionIndex.get(messageId); - if (indexedSession) { - return indexedSession; - } - for (const [candidateId, sessionMessages] of state.messages.entries()) { - if (sessionMessages.some((msg) => msg.info.id === messageId)) { - return candidateId; - } - } - return null; - }; - - set((state) => { - const targetSessionId = resolveSessionId(state); - if (!targetSessionId) { - return state; - } - - const sessionMessages = state.messages.get(targetSessionId) ?? []; - const messageIndex = resolveSessionMessagePosition(targetSessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return state; - } - - const message = sessionMessages[messageIndex]; - if (!message) { - return state; - } - - const now = Date.now(); - const existingInfo = message.info as any; - const existingCompleted = typeof existingInfo?.time?.completed === "number" && existingInfo.time.completed > 0; - - let infoChanged = false; - const updatedInfo: Record = { ...existingInfo }; - - if (!existingCompleted) { - updatedInfo.time = { - ...(existingInfo.time ?? {}), - completed: now, - }; - infoChanged = true; - } - - if (updatedInfo.status !== "completed") { - updatedInfo.status = "completed"; - infoChanged = true; - } - - if (updatedInfo.streaming) { - updatedInfo.streaming = false; - infoChanged = true; - } - - let partsChanged = false; - const updatedParts = message.parts.map((part) => { - if (!part) { - return part; - } - - if (part.type === "tool") { - const existingState = (part as any).state; - if (!existingState) { - return part; - } - - const status = existingState.status; - const needsStatusUpdate = status === "running" || status === "pending" || status === "started"; - const needsEndTimestamp = !existingState.time || typeof existingState.time?.end !== "number"; - - if (needsStatusUpdate || needsEndTimestamp) { - const nextState: Record = { ...existingState }; - if (needsStatusUpdate) { - nextState.status = "completed"; - } - if (needsEndTimestamp) { - nextState.time = { - ...(existingState.time ?? {}), - end: now, - }; - } - partsChanged = true; - return { - ...part, - state: nextState, - } as Part; - } - return part; - } - - if (part.type === "reasoning") { - const reasoningTime = (part as any).time; - if (!reasoningTime || typeof reasoningTime.end !== "number") { - partsChanged = true; - return { - ...part, - time: { - ...(reasoningTime ?? {}), - end: now, - }, - } as Part; - } - return part; - } - - if (part.type === "text") { - const textTime = (part as any).time; - if (textTime && typeof textTime.end !== "number") { - partsChanged = true; - return { - ...part, - time: { - ...textTime, - end: now, - }, - } as Part; - } - return part; - } - - return part; - }); - - if (!infoChanged && !partsChanged) { - return state; - } - - (window as any).__messageTracker?.(messageId, `force_complete:${source}`); - - const updatedMessage = { - ...message, - info: updatedInfo as Message, - parts: partsChanged ? updatedParts : message.parts, - }; - - const nextSessionMessages = [...sessionMessages]; - nextSessionMessages[messageIndex] = updatedMessage; - - const nextMessages = new Map(state.messages); - nextMessages.set(targetSessionId, nextSessionMessages); - - return { messages: nextMessages }; - }); - }, - - markMessageStreamSettled: (messageId: string) => { - set((state) => { - - clearLifecycleCompletionTimer(messageId); - const next = new Map(state.messageStreamStates); - next.delete(messageId); - - let updatedMessages = state.messages; - let messagesModified = false; - const indexedSessionId = state.messageSessionIndex.get(messageId); - const sessionIdCandidates = indexedSessionId - ? [ - indexedSessionId, - ...Array.from(state.messages.keys()).filter((sessionId) => sessionId !== indexedSessionId), - ] - : Array.from(state.messages.keys()); - - for (const sessionId of sessionIdCandidates) { - const sessionMessages = state.messages.get(sessionId); - if (!sessionMessages) { - continue; - } - const idx = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (idx === -1) { - continue; - } - - const message = sessionMessages[idx]; - if ((message.info as any)?.animationSettled) { - break; - } - - const updatedMessage = { - ...message, - info: { - ...message.info, - animationSettled: true, - }, - }; - - const sessionArray = [...sessionMessages]; - sessionArray[idx] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, sessionArray); - updatedMessages = newMessages; - messagesModified = true; - break; - } - - const updates: Partial & { messageStreamStates: Map } = { - messageStreamStates: next, - ...(messagesModified ? { messages: updatedMessages } : {}), - } as any; - - return updates; - }); - clearLifecycleTimersForIds([messageId]); - }, - - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => { - set((state) => { - const sessionMessages = state.messages.get(sessionId) ?? []; - const normalizedSessionMessages = [...sessionMessages]; - - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, normalizedSessionMessages); - const pendingEntry = state.pendingAssistantParts.get(messageId); - - const ensureClientRole = (info: any) => { - if (!info) { - return info; - } - return normalizeMessageInfoForProjection(info as Message) as any; - }; - - if (messageIndex === -1) { - if (process.env.NODE_ENV === 'development') { - console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { - sessionId, - messageId, - messageInfo, - existingCount: normalizedSessionMessages.length, - }); - } - - if (normalizedSessionMessages.length > 0) { - const firstMessage = normalizedSessionMessages[0]; - const firstInfo = firstMessage?.info as any; - const firstCreated = typeof firstInfo?.time?.created === 'number' ? firstInfo.time.created : null; - const firstId = typeof firstInfo?.id === 'string' ? firstInfo.id : null; - - const incomingInfoToCompare = messageInfo as any; - const incomingCreated = typeof incomingInfoToCompare?.time?.created === 'number' - ? incomingInfoToCompare.time.created - : null; - const incomingId = typeof incomingInfoToCompare?.id === 'string' ? incomingInfoToCompare.id : messageId; - - let isOlderThanViewport = false; - if (incomingCreated !== null && firstCreated !== null) { - isOlderThanViewport = incomingCreated < firstCreated; - } - if (!isOlderThanViewport && incomingId && firstId) { - isOlderThanViewport = incomingId.localeCompare(firstId) < 0; - } - - if (isOlderThanViewport) { - (window as any).__messageTracker?.(messageId, 'skipped_evicted_message_update'); - return state; - } - } - - const incomingInfo = ensureClientRole(messageInfo); - - if (incomingInfo && incomingInfo.role === 'user') { - const pendingParts = pendingEntry?.parts ?? []; - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - const newUserMessage = { - info: { - ...incomingInfo, - userMessageMarker: true, - clientRole: 'user', - ...(pendingMeta?.mode ? { mode: pendingMeta.mode } : {}), - ...(pendingMeta?.providerID ? { providerID: pendingMeta.providerID } : {}), - ...(pendingMeta?.modelID ? { modelID: pendingMeta.modelID } : {}), - } as Message, - parts: pendingParts.length > 0 ? [...pendingParts] : [], - }; - - const newMessages = new Map(state.messages); - - const appended = [...normalizedSessionMessages, newUserMessage]; - - appended.sort((a, b) => { - const aTime = (a.info as any)?.time?.created || 0; - const bTime = (b.info as any)?.time?.created || 0; - return aTime - bTime; - }); - newMessages.set(sessionId, appended); - primeSessionMessagePositionIndex(sessionId, appended); - - const updates: Partial = { - messages: newMessages, - ...(pendingMeta - ? { - pendingUserMessageMetaBySession: cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId), - } - : {}), - }; - - const nextIndex = upsertMessageSessionIndex( - updates.messageSessionIndex ?? state.messageSessionIndex, - messageId, - sessionId - ); - if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { - updates.messageSessionIndex = nextIndex; - } - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - } - - if (!incomingInfo || incomingInfo.role !== 'assistant') { - return state; - } - - const pendingParts = pendingEntry?.parts ?? []; - - const newMessage = { - info: { - ...incomingInfo, - animationSettled: (incomingInfo as any)?.animationSettled ?? false, - } as Message, - parts: pendingParts.length > 0 ? [...pendingParts] : [], - }; - - const newMessages = new Map(state.messages); - - const appended = [...normalizedSessionMessages, newMessage]; - newMessages.set(sessionId, appended); - updateSessionMessagePositionEntry(sessionId, messageId, appended.length - 1); - - const updates: Partial = { - messages: newMessages, - }; - - const nextIndex = upsertMessageSessionIndex( - updates.messageSessionIndex ?? state.messageSessionIndex, - messageId, - sessionId - ); - if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { - updates.messageSessionIndex = nextIndex; - } - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - } - - const existingMessage = normalizedSessionMessages[messageIndex]; - - const existingInfo = existingMessage.info as any; - const isUserMessage = - existingInfo.userMessageMarker === true || - existingInfo.clientRole === 'user' || - existingInfo.role === 'user'; - - if (isUserMessage) { - - const updatedInfo = { - ...existingMessage.info, - ...messageInfo, - - role: 'user', - clientRole: 'user', - userMessageMarker: true, - - providerID: existingInfo.providerID || undefined, - modelID: existingInfo.modelID || undefined, - } as any; - - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - if (pendingMeta && !updatedInfo.mode && pendingMeta.mode) { - updatedInfo.mode = pendingMeta.mode; - } - - const updatedMessage = { - ...existingMessage, - info: updatedInfo - }; - - const newMessages = new Map(state.messages); - const updatedSessionMessages = [...normalizedSessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - newMessages.set(sessionId, updatedSessionMessages); - - if (pendingMeta) { - const nextPending = new Map(state.pendingUserMessageMetaBySession); - nextPending.delete(sessionId); - return { messages: newMessages, pendingUserMessageMetaBySession: nextPending }; - } - - return { messages: newMessages }; - } - - - const updatedInfo = { - ...existingMessage.info, - ...messageInfo, - } as any; - - if (messageInfo.role && messageInfo.role !== existingMessage.info.role) { - updatedInfo.role = existingMessage.info.role; - } - - updatedInfo.clientRole = updatedInfo.clientRole ?? existingMessage.info.clientRole ?? existingMessage.info.role; - if (updatedInfo.clientRole === "user") { - updatedInfo.userMessageMarker = true; - } - - const updatedMessage = { - ...existingMessage, - info: updatedInfo, - parts: existingMessage.parts, - }; - - const newMessages = new Map(state.messages); - const updatedSessionMessages = [...normalizedSessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - newMessages.set(sessionId, updatedSessionMessages); - - const updates: Partial = { - messages: newMessages, - }; - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - }); - - // Trigger completion when info.finish is present for assistant messages - const infoFinish = (messageInfo as { finish?: string })?.finish; - const messageRole = (messageInfo as { role?: string })?.role; - if (typeof infoFinish === 'string' && messageRole !== 'user') { - setTimeout(() => { - const store = get(); - store.completeStreamingMessage(sessionId, messageId); - }, 0); - } - }, - - completeStreamingMessage: (sessionId: string, messageId: string) => { - flushQueuedNonTextStreamingPartsForMessage(get()._addStreamingPartImmediate, sessionId, messageId); - flushQueuedPartDeltasForMessage(get()._applyPartDeltaImmediate, sessionId, messageId); - - const state = get(); - - (window as any).__messageTracker?.( - messageId, - `completion_called_current:${state.streamingMessageIds.get(sessionId) ?? 'none'}` - ); - - if (typeof state.forceCompleteMessage === "function") { - state.forceCompleteMessage(sessionId, messageId, "cooldown"); - } - - const shouldClearStreamingId = state.streamingMessageIds.get(sessionId) === messageId; - if (shouldClearStreamingId) { - (window as any).__messageTracker?.(messageId, 'streamingId_cleared'); - } else { - (window as any).__messageTracker?.(messageId, 'streamingId_NOT_cleared_different_id'); - } - - const updates: Record = {}; - if (shouldClearStreamingId) { - updates.streamingMessageIds = setStreamingIdForSession(state.streamingMessageIds, sessionId, null); - updates.abortControllers = (() => { - const next = new Map(state.abortControllers); - next.delete(sessionId); - return next; - })(); - } - - if (state.messageStreamStates.has(messageId)) { - const next = new Map(state.messageStreamStates); - next.delete(messageId); - updates.messageStreamStates = next; - } - - if (Object.keys(updates).length > 0) { - set(updates); - } - - if (state.pendingAssistantParts.has(messageId)) { - set((currentState) => { - if (!currentState.pendingAssistantParts.has(messageId)) { - return currentState; - } - const nextPending = new Map(currentState.pendingAssistantParts); - nextPending.delete(messageId); - return { pendingAssistantParts: nextPending }; - }); - } - - clearLifecycleTimersForIds([messageId]); - - let startedCooldown = false; - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId); - if (!memoryState || !memoryState.isStreaming) return state; - - const newMemoryState = new Map(state.sessionMemoryState); - const now = Date.now(); - const updatedMemory: SessionMemoryState = { - ...memoryState, - isStreaming: false, - streamStartTime: undefined, - isZombie: false, - lastAccessedAt: now, - streamingCooldownUntil: now + 2000, - }; - newMemoryState.set(sessionId, updatedMemory); - startedCooldown = true; - return { sessionMemoryState: newMemoryState }; - }); - - if (startedCooldown) { - const existingTimer = streamingCooldownTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - streamingCooldownTimers.delete(sessionId); - } - - const timeoutId = setTimeout(() => { - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId); - if (!memoryState) return state; - - if (memoryState.isStreaming) { - return state; - } - - const nextMemoryState = new Map(state.sessionMemoryState); - const { streamingCooldownUntil: _streamingCooldownUntil, ...rest } = memoryState; - void _streamingCooldownUntil; - nextMemoryState.set(sessionId, rest as SessionMemoryState); - return { sessionMemoryState: nextMemoryState }; - }); - streamingCooldownTimers.delete(sessionId); - }, 2000); - - streamingCooldownTimers.set(sessionId, timeoutId); - } - }, - - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => { - flushQueuedNonTextStreamingPartsForSession(get()._addStreamingPartImmediate, sessionId); - flushQueuedPartDeltasForSession(get()._applyPartDeltaImmediate, sessionId); - - // Filter out reverted messages first - const revertMessageId = getSessionRevertMessageId(sessionId); - const messagesWithoutReverted = filterMessagesByRevertPoint(messages, revertMessageId); - - const messagesFiltered = messagesWithoutReverted; - const shouldReplace = options?.replace === true; - - set((state) => { - const newMessages = new Map(state.messages); - const previousMessages = state.messages.get(sessionId) || []; - const normalizedIncomingMessages = messagesFiltered.map((message) => { - const infoWithMarker = { - ...normalizeMessageInfoForProjection(message.info as Message), - animationSettled: - message.info.role === "assistant" - ? (message.info as any)?.animationSettled ?? true - : (message.info as any)?.animationSettled, - } as any; - - const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { - if (part?.type === 'text') { - const raw = (part as any).text ?? (part as any).content ?? ''; - if (isExecutionForkMetaText(raw)) { - return { ...part, synthetic: true } as Part; - } - } - return part; - }); - return { - ...message, - info: infoWithMarker, - parts: serverParts, - }; - }); - - const incomingIds = new Set( - normalizedIncomingMessages - .map((message) => (message?.info as { id?: unknown })?.id) - .filter((id): id is string => typeof id === 'string' && id.length > 0) - ); - - const existingOnlyMessages = shouldReplace - ? [] - : previousMessages.filter((message) => { - const id = (message?.info as { id?: unknown })?.id; - return typeof id === 'string' && id.length > 0 ? !incomingIds.has(id) : true; - }); - - const mergedMessages = dedupeMessagesById([ - ...existingOnlyMessages, - ...normalizedIncomingMessages, - ]).sort(compareMessageEntriesChronologically); - - const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); - const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); - const removedIds: string[] = []; - previousIds.forEach((id) => { - if (!nextIds.has(id)) { - removedIds.push(id); - } - }); - - newMessages.set(sessionId, mergedMessages); - primeSessionMessagePositionIndex(sessionId, mergedMessages); - - const result: Record = { - messages: newMessages, - isSyncing: true, - }; - - clearLifecycleTimersForIds(removedIds); - const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); - if (updatedLifecycle !== state.messageStreamStates) { - result.messageStreamStates = updatedLifecycle; - } - - if (removedIds.length > 0) { - const currentStreaming = state.streamingMessageIds.get(sessionId); - if (currentStreaming && removedIds.includes(currentStreaming)) { - result.streamingMessageIds = setStreamingIdForSession( - result.streamingMessageIds ?? state.streamingMessageIds, - sessionId, - null - ); - } - } - - if (removedIds.length > 0) { - const nextIndex = removeMessageSessionIndexEntries( - result.messageSessionIndex ?? state.messageSessionIndex, - removedIds - ); - if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { - result.messageSessionIndex = nextIndex; - } - } - - if (removedIds.length > 0) { - const nextPendingParts = new Map(state.pendingAssistantParts); - let pendingChanged = false; - removedIds.forEach((id) => { - if (nextPendingParts.delete(id)) { - pendingChanged = true; - } - }); - if (pendingChanged) { - result.pendingAssistantParts = nextPendingParts; - } - } - - const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; - let indexAccumulator = targetIndex; - mergedMessages.forEach((message) => { - const id = (message?.info as { id?: unknown })?.id; - if (typeof id === "string" && id.length > 0) { - indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); - } - }); - if (indexAccumulator !== targetIndex) { - result.messageSessionIndex = indexAccumulator; - } - - return result; - }); - - setTimeout(() => { - set({ isSyncing: false }); - }, 100); - }, - - updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => { - set((state) => { - const nextCompaction = new Map(state.sessionCompactionUntil); - - if (!compactingTimestamp || compactingTimestamp <= 0) { - if (!nextCompaction.has(sessionId)) { - return state; - } - nextCompaction.delete(sessionId); - return { sessionCompactionUntil: nextCompaction }; - } - - const deadline = compactingTimestamp + COMPACTION_WINDOW_MS; - const existingDeadline = nextCompaction.get(sessionId); - if (existingDeadline === deadline) { - return state; - } - - nextCompaction.set(sessionId, deadline); - return { sessionCompactionUntil: nextCompaction }; - }); - }, - - acknowledgeSessionAbort: (sessionId: string) => { - if (!sessionId) { - return; - } - - set((state) => { - const record = state.sessionAbortFlags.get(sessionId); - if (!record || record.acknowledged) { - return state; - } - - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.set(sessionId, { ...record, acknowledged: true }); - return { sessionAbortFlags: nextAbortFlags } as Partial; - }); - }, - - updateViewportAnchor: (sessionId: string, anchor: number) => { - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId) || { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - - if (memoryState.viewportAnchor === anchor) { - return state; - } - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { ...memoryState, viewportAnchor: anchor }); - return { sessionMemoryState: newMemoryState }; - }); - }, - - loadMoreMessages: async (sessionId: string, direction: "up" | "down" = "up") => { - const state = get(); - const currentMessages = state.messages.get(sessionId); - const memoryState = state.sessionMemoryState.get(sessionId); - const historyMeta = state.sessionHistoryMeta.get(sessionId); - - if (!currentMessages || !memoryState) { - return; - } - - if (historyMeta?.loading) { - return; - } - - if (historyMeta?.complete) { - return; - } - - const memLimits = getMemoryLimits(); - const baseLimit = historyMeta?.limit ?? memLimits.HISTORICAL_MESSAGES; - const desiredLimit = direction === "up" ? baseLimit + memLimits.HISTORY_CHUNK : baseLimit; - - if (desiredLimit <= baseLimit) { - return; - } - - await get().loadMessages(sessionId, desiredLimit); - }, - - getLastMessageModel: (sessionId: string) => { - const { messages } = get(); - const sessionMessages = messages.get(sessionId); - - if (!sessionMessages || sessionMessages.length === 0) { - return null; - } - - for (let i = sessionMessages.length - 1; i >= 0; i--) { - const message = sessionMessages[i]; - if (message.info.role === "assistant" && "providerID" in message.info && "modelID" in message.info) { - return { - providerID: (message.info as any).providerID, - modelID: (message.info as any).modelID, - }; - } - } - - return null; - }, - }), - { - name: "message-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state: MessageStore) => ({ - lastUsedProvider: state.lastUsedProvider, - sessionMemoryState: Array.from(state.sessionMemoryState.entries()).map(([sessionId, memory]) => [ - sessionId, - { - viewportAnchor: memory.viewportAnchor, - lastAccessedAt: memory.lastAccessedAt, - totalAvailableMessages: memory.totalAvailableMessages, - loadedTurnCount: memory.loadedTurnCount, - hasMoreAbove: memory.hasMoreAbove, - hasMoreTurnsAbove: memory.hasMoreTurnsAbove, - historyLoading: memory.historyLoading, - historyComplete: memory.historyComplete, - historyLimit: memory.historyLimit, - }, - ]), - sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [ - sessionId, - { timestamp: record.timestamp, acknowledged: record.acknowledged }, - ]), - }), - merge: (persistedState: any, currentState: MessageStore): MessageStore => { - if (!persistedState) { - return currentState; - } - - let restoredMemoryState = currentState.sessionMemoryState; - if (Array.isArray(persistedState.sessionMemoryState)) { - restoredMemoryState = new Map( - persistedState.sessionMemoryState.map((entry: [string, SessionMemoryState]) => { - const [id, memory] = entry; - // Never trust persisted history flags — they must be - // recomputed from a fresh API fetch on session open. - return [id, { - ...memory, - isStreaming: false, - backgroundMessageCount: typeof memory.backgroundMessageCount === 'number' - ? memory.backgroundMessageCount - : 0, - hasMoreAbove: undefined, - hasMoreTurnsAbove: undefined, - historyComplete: undefined, - historyLimit: undefined, - historyLoading: false, - }] as [string, SessionMemoryState]; - }) - ); - } - - let restoredAbortFlags = currentState.sessionAbortFlags; - if (Array.isArray(persistedState.sessionAbortFlags)) { - restoredAbortFlags = new Map(persistedState.sessionAbortFlags); - } - - return { - ...currentState, - lastUsedProvider: persistedState.lastUsedProvider ?? currentState.lastUsedProvider, - sessionMemoryState: restoredMemoryState, - sessionAbortFlags: restoredAbortFlags, - }; - }, - } - ), - { - name: "message-store", - } - ) -); diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index afef7809..a3eed141 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,65 +1,28 @@ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { opencodeClient } from "@/lib/opencode/client"; import type { Session } from "@opencode-ai/sdk/v2/client"; -import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import { + autoRespondsPermission, normalizeDirectory, + sessionAcceptKey, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; import { getSafeStorage } from "./utils/safeStorage"; -import { useMessageStore } from "./messageStore"; -import { useSessionStore } from "./sessionStore"; +import { getAllSyncSessions } from "@/sync/sync-refs"; +import { opencodeClient } from "@/lib/opencode/client"; +import { useSessionUIStore } from "@/sync/session-ui-store"; interface PermissionState { - permissions: Map; autoAccept: PermissionAutoAcceptMap; } interface PermissionActions { - addPermission: (permission: PermissionRequest) => void; - respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; - dismissPermission: (sessionId: string, requestId: string) => void; isSessionAutoAccepting: (sessionId: string) => boolean; setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise; } type PermissionStore = PermissionState & PermissionActions; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => { - if (!Array.isArray(value)) { - return []; - } - const entries: Array<[string, PermissionRequest[]]> = []; - value.forEach((entry) => { - if (!Array.isArray(entry) || entry.length !== 2) { - return; - } - const [sessionId, permissions] = entry; - if (typeof sessionId !== "string" || !Array.isArray(permissions)) { - return; - } - entries.push([sessionId, permissions as PermissionRequest[]]); - }); - return entries; -}; - -const executeWithPermissionDirectory = async (sessionId: string, operation: () => Promise): Promise => { - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - if (directory) { - return opencodeClient.withDirectory(directory, operation); - } - } catch (error) { - console.warn('Failed to resolve session directory for permission handling:', error); - } - return operation(); -}; - const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { const map = new Map(); for (const session of sessions) { @@ -82,148 +45,40 @@ const autoRespondsPermissionBySession = ( sessions: Session[], sessionID: string, ): boolean => { - for (const id of resolveLineage(sessionID, sessions)) { - if (id in autoAccept) { - return autoAccept[id] === true; + const targetSession = sessions.find((session) => session.id === sessionID); + const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionID); + const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); + if (!directory) { + for (const id of resolveLineage(sessionID, sessions)) { + if (id in autoAccept) { + return autoAccept[id] === true; + } } - } - return false; -}; - -const shouldAutoRespond = (permission: PermissionRequest, autoAccept: PermissionAutoAcceptMap): boolean => { - if (!permission?.sessionID) { return false; } - const sessionStore = useSessionStore.getState(); - const sessions = sessionStore.sessions; - return autoRespondsPermissionBySession( + return autoRespondsPermission({ autoAccept, sessions, - permission.sessionID, - ); + sessionID, + directory, + }); }; -const collectPermissionDirectories = (fallbackDirectory?: string | null): string[] => { - const sessionStore = useSessionStore.getState(); - const dirs = new Set(); - const fallback = normalizeDirectory(fallbackDirectory); - if (fallback) { - dirs.add(fallback); - } - - const currentDirectory = normalizeDirectory(opencodeClient.getDirectory()); - if (currentDirectory) { - dirs.add(currentDirectory); - } - - for (const session of sessionStore.sessions) { - const normalized = normalizeDirectory((session as { directory?: string | null }).directory); - if (normalized) { - dirs.add(normalized); - } - } - - return Array.from(dirs); -}; - -const reconcilePendingAutoAccept = async ( - autoAccept: PermissionAutoAcceptMap, - fallbackDirectory?: string | null, -) => { - const directories = collectPermissionDirectories(fallbackDirectory); - if (directories.length === 0) { - return; - } - - const pending = await opencodeClient.listPendingPermissions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - if (!request?.sessionID || !request?.id) { - continue; - } - if (!shouldAutoRespond(request, autoAccept)) { - continue; - } - try { - await executeWithPermissionDirectory(request.sessionID, () => opencodeClient.replyToPermission(request.id, 'once')); - } catch { - // ignored - } - } -}; +const getStorage = () => createJSONStorage(() => getSafeStorage()); export const usePermissionStore = create()( devtools( persist( (set, get) => ({ - - permissions: new Map(), autoAccept: {}, - addPermission: (permission: PermissionRequest) => { - const sessionId = permission.sessionID; - if (!sessionId) { - return; - } - - const existing = get().permissions.get(sessionId); - if (existing?.some((entry) => entry.id === permission.id)) { - return; - } - - if (shouldAutoRespond(permission, get().autoAccept)) { - get().respondToPermission(sessionId, permission.id, 'once').catch(() => { - - }); - return; - } - - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, [...sessionPermissions, permission]); - return { permissions: newPermissions }; - }); - }, - - respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => { - await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response)); - - if (response === 'reject') { - const messageStore = useMessageStore.getState(); - - await messageStore.abortCurrentOperation(sessionId); - } - - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, updatedPermissions); - return { permissions: newPermissions }; - }); - }, - - dismissPermission: (sessionId: string, requestId: string) => { - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, updatedPermissions); - return { permissions: newPermissions }; - }); - }, - isSessionAutoAccepting: (sessionId: string) => { if (!sessionId) { return false; } - const sessions = useSessionStore.getState().sessions; + const sessions = getAllSyncSessions(); return autoRespondsPermissionBySession(get().autoAccept, sessions, sessionId); }, @@ -232,49 +87,59 @@ export const usePermissionStore = create()( return; } - set((state) => ({ - autoAccept: { - ...state.autoAccept, - [sessionId]: enabled, - }, - })); + const sessions = getAllSyncSessions(); + const targetSession = sessions.find((session) => session.id === sessionId); + const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId); + const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); + const key = directory ? sessionAcceptKey(sessionId, directory) : sessionId; - if (!enabled) { + set((state) => { + const autoAccept = { ...state.autoAccept }; + if (directory) { + delete autoAccept[sessionId]; + } + autoAccept[key] = enabled; + return { autoAccept }; + }); + + if (!enabled || !directory) { return; } - const sessionDirectory = useSessionStore.getState().getDirectoryForSession(sessionId); - void reconcilePendingAutoAccept(get().autoAccept, sessionDirectory); + + const pending = await opencodeClient.listPendingPermissions({ directories: [directory] }); + const client = opencodeClient.getScopedSdkClient(directory); + const sessionLineage = new Set(resolveLineage(sessionId, sessions)); + await Promise.all( + pending + .filter((permission) => sessionLineage.has(permission.sessionID)) + .map((permission) => client.permission.reply({ requestID: permission.id, reply: "once" }).catch(() => undefined)), + ); }, }), { name: "permission-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - permissions: Array.from(state.permissions.entries()), - autoAccept: state.autoAccept, - }), + storage: getStorage(), + partialize: (state) => ({ autoAccept: state.autoAccept }), merge: (persistedState, currentState) => { - if (!isRecord(persistedState)) { - return currentState; - } - const entries = sanitizePermissionEntries(persistedState.permissions); - const autoAccept = isRecord(persistedState.autoAccept) - ? Object.fromEntries( - Object.entries(persistedState.autoAccept).filter((entry): entry is [string, boolean] => { - return typeof entry[0] === "string" && typeof entry[1] === "boolean"; - }), - ) - : {}; - return { + const merged = { ...currentState, - permissions: new Map(entries), - autoAccept, + ...(persistedState as Partial), + }; + + const nextAutoAccept = Object.fromEntries( + Object.entries(merged.autoAccept || {}).map(([sessionId, enabled]) => [ + sessionId, + Boolean(enabled), + ]), + ); + + return { + ...merged, + autoAccept: nextAutoAccept, }; }, } ), - { - name: "permission-store", - } + { name: "permission-store" } ) ); diff --git a/packages/ui/src/stores/questionStore.ts b/packages/ui/src/stores/questionStore.ts deleted file mode 100644 index 5d39b2e7..00000000 --- a/packages/ui/src/stores/questionStore.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { opencodeClient } from "@/lib/opencode/client"; -import type { QuestionRequest } from "@/types/question"; -import { getSafeStorage } from "./utils/safeStorage"; -import { useSessionStore } from "./sessionStore"; - -interface QuestionState { - questions: Map; -} - -interface QuestionActions { - addQuestion: (question: QuestionRequest) => void; - dismissQuestion: (sessionId: string, requestId: string) => void; - respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise; - rejectQuestion: (sessionId: string, requestId: string) => Promise; -} - -type QuestionStore = QuestionState & QuestionActions; - -const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; - -const sanitizeQuestionEntries = (value: unknown): Array<[string, QuestionRequest[]]> => { - if (!Array.isArray(value)) { - return []; - } - const entries: Array<[string, QuestionRequest[]]> = []; - value.forEach((entry) => { - if (!Array.isArray(entry) || entry.length !== 2) { - return; - } - const [sessionId, questions] = entry; - if (typeof sessionId !== "string" || !Array.isArray(questions)) { - return; - } - entries.push([sessionId, questions as QuestionRequest[]]); - }); - return entries; -}; - -const executeWithQuestionDirectory = async (sessionId: string, operation: () => Promise): Promise => { - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - if (directory) { - return opencodeClient.withDirectory(directory, operation); - } - } catch (error) { - console.warn("Failed to resolve session directory for question handling:", error); - } - return operation(); -}; - -export const useQuestionStore = create()( - devtools( - persist( - (set, get) => ({ - questions: new Map(), - - addQuestion: (question: QuestionRequest) => { - const sessionId = question.sessionID; - if (!sessionId) { - return; - } - - const existing = get().questions.get(sessionId); - if (existing?.some((entry) => entry.id === question.id)) { - return; - } - - set((state) => { - const sessionQuestions = state.questions.get(sessionId) || []; - const next = new Map(state.questions); - next.set(sessionId, [...sessionQuestions, question]); - return { questions: next }; - }); - }, - - dismissQuestion: (sessionId: string, requestId: string) => { - if (!sessionId || !requestId) { - return; - } - - set((state) => { - const sessionQuestions = state.questions.get(sessionId) || []; - const updated = sessionQuestions.filter((q) => q.id !== requestId); - const next = new Map(state.questions); - next.set(sessionId, updated); - return { questions: next }; - }); - }, - - respondToQuestion: async (sessionId: string, requestId: string, answers: string[] | string[][]) => { - await executeWithQuestionDirectory(sessionId, () => opencodeClient.replyToQuestion(requestId, answers)); - get().dismissQuestion(sessionId, requestId); - }, - - rejectQuestion: async (sessionId: string, requestId: string) => { - await executeWithQuestionDirectory(sessionId, () => opencodeClient.rejectQuestion(requestId)); - get().dismissQuestion(sessionId, requestId); - }, - }), - { - name: "question-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - questions: Array.from(state.questions.entries()), - }), - merge: (persistedState, currentState) => { - if (!isRecord(persistedState)) { - return currentState; - } - const entries = sanitizeQuestionEntries(persistedState.questions); - return { - ...currentState, - questions: new Map(entries), - }; - }, - } - ), - { name: "question-store" } - ) -); diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts deleted file mode 100644 index d543dae4..00000000 --- a/packages/ui/src/stores/sessionStore.ts +++ /dev/null @@ -1,1750 +0,0 @@ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Session } from "@opencode-ai/sdk/v2"; -import { opencodeClient } from "@/lib/opencode/client"; -import { getSafeStorage } from "./utils/safeStorage"; -import type { WorktreeMetadata } from "@/types/worktree"; -import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus"; -import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager"; -import { useDirectoryStore } from "./useDirectoryStore"; -import { useProjectsStore } from "./useProjectsStore"; -import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus"; -import type { ProjectEntry } from "@/lib/api/types"; -import { checkIsGitRepository } from "@/lib/gitApi"; -import { streamDebugEnabled } from "@/stores/utils/streamDebug"; -import { isMissingGlobalSessionsEndpointError, readNextCursor, type GlobalSessionRecord } from "./globalSessions"; - -interface SessionState { - sessions: Session[]; - archivedSessions: Session[]; - sessionsByDirectory: Map; - currentSessionId: string | null; - lastLoadedDirectory: string | null; - isLoading: boolean; - error: string | null; - webUICreatedSessions: Set; - worktreeMetadata: Map; - availableWorktrees: WorktreeMetadata[]; - availableWorktreesByProject: Map; -} - -interface SessionActions { - loadSessions: () => Promise; - createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; - deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; - deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; - archiveSession: (id: string) => Promise; - archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>; - updateSessionTitle: (id: string, title: string) => Promise; - shareSession: (id: string) => Promise; - unshareSession: (id: string) => Promise; - setCurrentSession: (id: string | null) => void; - clearError: () => void; - getSessionsByDirectory: (directory: string) => Session[]; - getDirectoryForSession: (sessionId: string) => string | null; - applySessionMetadata: (sessionId: string, metadata: Partial) => void; - isOpenChamberCreatedSession: (sessionId: string) => boolean; - markSessionAsOpenChamberCreated: (sessionId: string) => void; - initializeNewOpenChamberSession: (sessionId: string, agents: Record[]) => void; - setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void; - getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined; - setSessionDirectory: (sessionId: string, directory: string | null) => void; - updateSession: (session: Session) => void; - removeSessionFromStore: (sessionId: string) => void; -} - -type SessionStore = SessionState & SessionActions; - -const safeStorage = getSafeStorage(); -const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory"; -type SessionSelectionMap = Record; - -const readSessionSelectionMap = (): SessionSelectionMap => { - try { - const raw = safeStorage.getItem(SESSION_SELECTION_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return {}; - } - return Object.entries(parsed as Record).reduce((acc, [directory, sessionId]) => { - if (typeof directory === "string" && typeof sessionId === "string" && directory.length > 0 && sessionId.length > 0) { - acc[directory] = sessionId; - } - return acc; - }, {}); - } catch { - return {}; - } -}; - -let sessionSelectionCache: SessionSelectionMap | null = null; -let loadSessionsRequestSeq = 0; -let loadSessionsInFlight: Promise | null = null; -let loadSessionsQueued = false; -let persistSelectionTimer: ReturnType | undefined; -let pendingSelectionMap: SessionSelectionMap | null = null; - -type ProjectSessionResult = { - projectId: string; - projectPath: string | null; - sessions: Session[]; - discoveredWorktrees: WorktreeMetadata[]; - validPaths: Set; -}; - -type ProjectSessionCacheEntry = { - cachedAt: number; - result: ProjectSessionResult; -}; - -type ProjectRepoCacheEntry = { - cachedAt: number; - isGitRepo: boolean; -}; - -const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000; -const projectSessionCache = new Map(); -const projectRepoStatusCache = new Map(); - -const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => { - const key = normalizePath(projectPath) ?? projectPath; - projectSessionCache.set(key, { cachedAt: Date.now(), result }); -}; - -const pruneProjectCaches = (validProjectPaths: Iterable) => { - const valid = new Set(); - for (const path of validProjectPaths) { - const normalized = normalizePath(path) ?? path; - if (normalized) { - valid.add(normalized); - } - } - - for (const key of projectSessionCache.keys()) { - if (!valid.has(key)) { - projectSessionCache.delete(key); - } - } - for (const key of projectRepoStatusCache.keys()) { - if (!valid.has(key)) { - projectRepoStatusCache.delete(key); - } - } -}; - -const getProjectRepoStatus = async (projectPath: string): Promise => { - const key = normalizePath(projectPath) ?? projectPath; - const cached = projectRepoStatusCache.get(key); - if (cached && Date.now() - cached.cachedAt <= PROJECT_REPO_STATUS_CACHE_TTL_MS) { - return cached.isGitRepo; - } - - const isGitRepo = await checkIsGitRepository(key).catch(() => false); - projectRepoStatusCache.set(key, { cachedAt: Date.now(), isGitRepo }); - return isGitRepo; -}; - -const getSessionSelectionMap = (): SessionSelectionMap => { - if (!sessionSelectionCache) { - sessionSelectionCache = readSessionSelectionMap(); - } - return sessionSelectionCache; -}; - -const persistSessionSelectionMap = (map: SessionSelectionMap) => { - sessionSelectionCache = map; - pendingSelectionMap = map; - clearTimeout(persistSelectionTimer); - persistSelectionTimer = setTimeout(() => { - try { - safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map)); - pendingSelectionMap = null; - } catch { /* ignored */ } - }, 300); -}; - -if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (pendingSelectionMap !== null) { - clearTimeout(persistSelectionTimer); - try { - safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(pendingSelectionMap)); - } catch { /* ignored */ } - pendingSelectionMap = null; - } - }); -} - -const getStoredSessionForDirectory = (directory: string | null | undefined): string | null => { - if (!directory) { - return null; - } - const map = getSessionSelectionMap(); - const selection = map[directory]; - return typeof selection === "string" ? selection : null; -}; - -const storeSessionForDirectory = (directory: string | null | undefined, sessionId: string | null) => { - if (!directory) { - return; - } - const map = { ...getSessionSelectionMap() }; - if (sessionId) { - map[directory] = sessionId; - } else { - delete map[directory]; - } - persistSessionSelectionMap(map); -}; - -const clearInvalidSessionSelection = (directory: string | null | undefined, validIds: Iterable) => { - if (!directory) { - return; - } - const storedSelection = getStoredSessionForDirectory(directory); - if (!storedSelection) { - return; - } - const validSet = new Set(validIds); - if (!validSet.has(storedSelection)) { - const map = { ...getSessionSelectionMap() }; - delete map[directory]; - persistSessionSelectionMap(map); - } -}; - -const archiveSessionWorktree = async ( - metadata: WorktreeMetadata, - options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string } -) => { - const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined)); - - const projects = useProjectsStore.getState().projects; - const normalizedProject = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory; - const projectEntry = projects.find((project) => normalizePath(project.path) === normalizedProject); - - const projectRef = { - id: projectEntry?.id ?? `path:${normalizedProject}`, - path: normalizedProject, - }; - - await removeProjectWorktree( - projectRef, - status ? ({ ...metadata, status } as WorktreeMetadata) : metadata, - { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - } - ); -}; - -const deleteSessionOnServer = async (sessionId: string, directory?: string | null): Promise => { - const apiClient = opencodeClient.getApiClient(); - const normalizedDirectory = normalizePath(directory ?? null); - const response = await apiClient.session.delete({ - sessionID: sessionId, - ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), - }); - return Boolean(response.data); -}; - -const setSessionArchivedOnServer = async ( - sessionId: string, - archivedAt: number, - directory?: string | null, -): Promise => { - const apiClient = opencodeClient.getApiClient(); - const normalizedDirectory = normalizePath(directory ?? null); - const response = await apiClient.session.update({ - sessionID: sessionId, - ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), - time: { archived: archivedAt }, - }); - return response.data ?? null; -}; - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const replaced = trimmed - .replace(/\\/g, "/") - .replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`) - .replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`); - if (replaced === "/") { - return "/"; - } - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; -}; - -const readVSCodeWorkspaceDirectory = (): string | null => { - if (typeof window === "undefined") { - return null; - } - const config = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__; - const workspaceFolder = typeof config?.workspaceFolder === "string" ? config.workspaceFolder : null; - return normalizePath(workspaceFolder); -}; - -const isVSCodeRuntime = (): boolean => { - if (typeof window === "undefined") return false; - const runtime = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) - .__OPENCHAMBER_RUNTIME_APIS__?.runtime; - return Boolean(runtime?.isVSCode); -}; - -const vscodeDebugLog = (...args: unknown[]) => { - if (!streamDebugEnabled()) return; - if (!isVSCodeRuntime()) return; - console.log("[OpenChamber][VSCode][sessions]", ...args); -}; - -const dedupeSessionsById = (sessions: Session[]): Session[] => { - const map = new Map(); - - sessions.forEach((session) => { - if (!session || typeof session.id !== "string" || session.id.length === 0) { - return; - } - - const existing = map.get(session.id); - if (!existing) { - map.set(session.id, session); - return; - } - - const existingUpdated = (existing as { time?: { updated?: number | null } }).time?.updated ?? 0; - const candidateUpdated = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; - if (candidateUpdated > existingUpdated) { - map.set(session.id, session); - } - }); - - return Array.from(map.values()); -}; - -const buildSessionsByDirectory = (sessions: Session[]): Map => { - const map = new Map(); - - sessions.forEach((session) => { - const directory = normalizePath((session as { directory?: string | null }).directory ?? null); - if (!directory) { - return; - } - - const existing = map.get(directory); - if (existing) { - existing.push(session); - } else { - map.set(directory, [session]); - } - }); - - for (const [key, value] of map.entries()) { - map.set(key, dedupeSessionsById(value)); - } - - return map; -}; - -const getSessionDirectory = (sessions: Session[], sessionId: string): string | null => { - const target = sessions.find((session) => session.id === sessionId); - if (!target) { - return null; - } - return normalizePath((target as { directory?: string | null }).directory ?? null); -}; - -const hydrateSessionWorktreeMetadata = async ( - sessions: Session[], - projectDirectory: string | null, - existingMetadata: Map, - preloadedWorktrees?: WorktreeMetadata[] -): Promise | null> => { - const normalizedProject = normalizePath(projectDirectory); - if (!normalizedProject || sessions.length === 0) { - return null; - } - - const sessionsWithDirectory = sessions - .map((session) => ({ id: session.id, directory: normalizePath((session as { directory?: string }).directory) })) - .filter((entry): entry is { id: string; directory: string } => Boolean(entry.directory)); - - if (sessionsWithDirectory.length === 0) { - return null; - } - - let worktreeEntries: WorktreeMetadata[]; - if (Array.isArray(preloadedWorktrees)) { - worktreeEntries = preloadedWorktrees; - } else { - try { - worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject }); - } catch (error) { - console.debug("Failed to hydrate worktree metadata from worktree list:", error); - return null; - } - } - - if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) { - let mutated = false; - const next = new Map(existingMetadata); - sessionsWithDirectory.forEach(({ id }) => { - if (next.delete(id)) { - mutated = true; - } - }); - return mutated ? next : null; - } - - const worktreeMapByPath = new Map(); - worktreeEntries.forEach((metadata) => { - const normalizedPath = normalizePath(metadata.path) ?? metadata.path; - - if (normalizedPath === normalizedProject) { - return; - } - - worktreeMapByPath.set(normalizedPath, metadata); - }); - - let mutated = false; - const next = new Map(existingMetadata); - - const mergeHydratedMetadata = ( - hydrated: WorktreeMetadata, - previous?: WorktreeMetadata - ): WorktreeMetadata => { - if (!previous) { - return hydrated; - } - return { - ...previous, - ...hydrated, - branch: hydrated.branch || previous.branch, - label: hydrated.label || previous.label, - name: hydrated.name || previous.name, - projectDirectory: hydrated.projectDirectory || previous.projectDirectory, - createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch, - kind: hydrated.kind || previous.kind, - status: hydrated.status || previous.status, - }; - }; - - sessionsWithDirectory.forEach(({ id, directory }) => { - const metadata = worktreeMapByPath.get(directory); - if (!metadata) { - if (next.delete(id)) { - mutated = true; - } - return; - } - - const previous = next.get(id); - const merged = mergeHydratedMetadata(metadata, previous); - if ( - !previous || - previous.path !== merged.path || - previous.branch !== merged.branch || - previous.label !== merged.label || - previous.name !== merged.name || - previous.projectDirectory !== merged.projectDirectory || - previous.createdFromBranch !== merged.createdFromBranch || - previous.kind !== merged.kind || - previous.source !== merged.source - ) { - next.set(id, merged); - mutated = true; - } - }); - - return mutated ? next : null; -}; - -export const useSessionStore = create()( - devtools( - persist( - (set, get) => ({ - - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - isLoading: false, - error: null, - webUICreatedSessions: new Set(), - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - - loadSessions: async () => { - if (loadSessionsInFlight) { - loadSessionsQueued = true; - return loadSessionsInFlight; - } - - const task = (async () => { - const requestSeq = ++loadSessionsRequestSeq; - const isLatestRequest = () => requestSeq === loadSessionsRequestSeq; - set({ isLoading: true, error: null }); - try { - const directoryStore = useDirectoryStore.getState(); - const projectsStore = useProjectsStore.getState(); - const apiClient = opencodeClient.getApiClient(); - const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); - const includeDescendants = Boolean(vscodeWorkspaceDirectory); - - vscodeDebugLog("loadSessions:start", { - workspace: vscodeWorkspaceDirectory, - currentDirectory: directoryStore.currentDirectory, - clientDirectory: opencodeClient.getDirectory(), - projectsCount: projectsStore.projects.length, - activeProjectId: projectsStore.activeProjectId, - }); - - const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null); - const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null; - const activeProjectRoot = normalizePath(activeProject?.path ?? null); - - const legacyRoot = activeProjectRoot ?? normalizedFallback ?? null; - - const projectEntries: Array> = projectsStore.projects.length > 0 - ? projectsStore.projects - : (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []); - - const resolveSessionDirectory = (session: Session): string | null => { - const direct = normalizePath((session as { directory?: string | null }).directory ?? null); - if (direct) { - return direct; - } - const projectWorktree = normalizePath((session as GlobalSessionRecord).project?.worktree ?? null); - return projectWorktree; - }; - - const matchesProjectDirectory = (sessionDirectory: string | null, projectDirectory: string): boolean => { - if (!sessionDirectory) { - return false; - } - if (sessionDirectory === projectDirectory) { - return true; - } - return includeDescendants && sessionDirectory.startsWith(`${projectDirectory}/`); - }; - - const applyProjectResults = async (projectResults: ProjectSessionResult[], archivedSessions: Session[]) => { - const sessionsByDirectory = new Map(); - projectResults.forEach((result) => { - if (!result.projectPath) { - return; - } - - result.validPaths.forEach((directory) => { - const directoryKey = normalizePath(directory) ?? directory; - const directorySessions = result.sessions.filter((session) => { - const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey; - return dir === directoryKey; - }); - sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions)); - }); - }); - - const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat()); - const stateSnapshot = get(); - - let nextWorktreeMetadata = stateSnapshot.worktreeMetadata; - for (const result of projectResults) { - if (!result.projectPath) { - continue; - } - try { - const hydratedMetadata = await hydrateSessionWorktreeMetadata( - result.sessions, - result.projectPath, - nextWorktreeMetadata, - result.discoveredWorktrees - ); - if (hydratedMetadata) { - nextWorktreeMetadata = hydratedMetadata; - } - } catch (metadataError) { - console.debug("Failed to refresh worktree metadata during session load:", metadataError); - } - } - - const worktreesByProject = new Map(); - projectResults.forEach((result) => { - if (result.projectPath) { - worktreesByProject.set(result.projectPath, result.discoveredWorktrees); - } - }); - - const allValidPaths = new Set(); - projectResults.forEach((result) => { - result.validPaths.forEach((value) => { - const key = normalizePath(value) ?? value; - if (key) { - allValidPaths.add(key); - } - }); - }); - - const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null; - const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate) - ? activeDirectoryCandidate - : (activeProjectRoot ?? activeDirectoryCandidate); - - const activeDirectorySessions = activeDirectory - ? sessionsByDirectory.get(activeDirectory) ?? [] - : mergedSessions; - - const validSessionIds = new Set(mergedSessions.map((session) => session.id)); - - // Keep directory-scoped stored selections tidy. - for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) { - clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id)); - } - - const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null); - - let nextCurrentId = stateSnapshot.currentSessionId; - const currentSessionInActiveDirectory = Boolean( - nextCurrentId && activeDirectorySessions.some((session) => session.id === nextCurrentId) - ); - if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || (directoryChanged && !currentSessionInActiveDirectory)) { - nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null; - } - - if (activeDirectory) { - const storedSelection = getStoredSessionForDirectory(activeDirectory); - if (storedSelection && validSessionIds.has(storedSelection)) { - nextCurrentId = storedSelection; - } - } - - const resolvedDirectoryForCurrent = (() => { - if (!nextCurrentId) { - return activeDirectory ?? null; - } - const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path; - if (metadataPath) { - return normalizePath(metadataPath) ?? metadataPath; - } - const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId); - if (sessionDir) { - return sessionDir; - } - return activeDirectory ?? null; - })(); - - if (!isLatestRequest()) { - return; - } - - try { - opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined); - } catch (error) { - console.warn("Failed to sync OpenCode directory after session load:", error); - } - - const activeWorktrees = activeProjectRoot - ? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? [] - : []; - - set({ - sessions: mergedSessions, - archivedSessions, - sessionsByDirectory, - currentSessionId: nextCurrentId, - lastLoadedDirectory: activeDirectory ?? null, - isLoading: false, - worktreeMetadata: nextWorktreeMetadata, - availableWorktrees: activeWorktrees, - availableWorktreesByProject: worktreesByProject, - }); - - if (activeDirectory) { - storeSessionForDirectory(activeDirectory, nextCurrentId); - } - if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) { - storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId); - } - }; - - if (projectEntries.length === 0) { - if (!isLatestRequest()) { - return; - } - set({ - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - isLoading: false, - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - }); - return; - } - - pruneProjectCaches(projectEntries.map((entry) => entry.path)); - - const buildProjectResults = async (sourceSessions: Session[]): Promise => { - return Promise.all( - projectEntries.map(async (project: Pick) => { - const normalizedProject = normalizePath(project.path); - if (!normalizedProject) { - return { - projectId: project.id, - projectPath: null, - sessions: [], - discoveredWorktrees: [], - validPaths: new Set(), - }; - } - - const isGitRepo = await getProjectRepoStatus(normalizedProject); - let discoveredWorktrees: WorktreeMetadata[] = []; - const validPaths = new Set([normalizedProject]); - if (isGitRepo) { - discoveredWorktrees = await listProjectWorktrees({ - id: project.id, - path: normalizedProject, - }).catch(() => []); - discoveredWorktrees.forEach((meta) => { - if (meta?.path) { - validPaths.add(normalizePath(meta.path) ?? meta.path); - } - }); - } - - const mergedSessions = dedupeSessionsById( - sourceSessions.filter((session) => { - const sessionDirectory = resolveSessionDirectory(session); - if (!sessionDirectory) { - return false; - } - for (const projectPath of validPaths) { - if (matchesProjectDirectory(sessionDirectory, projectPath)) { - return true; - } - } - return false; - }), - ); - - const result: ProjectSessionResult = { - projectId: project.id, - projectPath: normalizedProject, - sessions: mergedSessions, - discoveredWorktrees, - validPaths, - }; - setProjectSessionCache(normalizedProject, result); - return result; - }), - ); - }; - - try { - const pageSize = 500; - const previousArchivedSessions = dedupeSessionsById(get().archivedSessions); - const firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false }); - let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []); - let archivedSessions: Session[] = []; - let hasLoadedArchivedSessions = false; - - const apply = async () => { - if (!isLatestRequest()) { - return; - } - const projectResults = await buildProjectResults(liveSessions); - const archivedForRender = hasLoadedArchivedSessions - ? dedupeSessionsById(archivedSessions) - : previousArchivedSessions; - await applyProjectResults(projectResults, archivedForRender); - }; - - await apply(); - - const backgroundLoad = async () => { - let cursor = readNextCursor(firstPage) ?? undefined; - while (cursor && isLatestRequest()) { - const response = await apiClient.experimental.session.list({ - limit: pageSize, - cursor, - archived: false, - }); - const page = Array.isArray(response.data) ? response.data as Session[] : []; - if (page.length === 0) { - break; - } - liveSessions = dedupeSessionsById([...liveSessions, ...page]); - await apply(); - cursor = readNextCursor(response) ?? undefined; - } - - let archivedCursor: number | undefined; - while (isLatestRequest()) { - const response = await apiClient.experimental.session.list({ - limit: pageSize, - archived: true, - ...(archivedCursor ? { cursor: archivedCursor } : {}), - }); - const page = Array.isArray(response.data) - ? (response.data as Session[]).filter((session) => Boolean(session.time?.archived)) - : []; - if (page.length > 0) { - hasLoadedArchivedSessions = true; - archivedSessions = dedupeSessionsById([...archivedSessions, ...page]); - await apply(); - } - const next = readNextCursor(response); - if (!next) { - break; - } - archivedCursor = next; - } - - if (!hasLoadedArchivedSessions && isLatestRequest()) { - hasLoadedArchivedSessions = true; - archivedSessions = []; - await apply(); - } - }; - - void backgroundLoad().catch((error) => { - console.debug("Failed to load additional global sessions:", error); - }); - - return; - } catch (error) { - if (!isMissingGlobalSessionsEndpointError(error)) { - throw error; - } - console.debug("Global session endpoint unavailable, using legacy loader"); - } - - const fallbackResponse = await apiClient.session.list(undefined); - const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []); - const fallbackProjectResults = await buildProjectResults(fallbackSessions); - await applyProjectResults(fallbackProjectResults, []); - } catch (error) { - if (!isLatestRequest()) { - return; - } - set({ - error: error instanceof Error ? error.message : "Failed to load sessions", - isLoading: false, - }); - } - })(); - - loadSessionsInFlight = task; - try { - await task; - } finally { - if (loadSessionsInFlight === task) { - loadSessionsInFlight = null; - } - if (loadSessionsQueued) { - loadSessionsQueued = false; - void get().loadSessions(); - } - } - }, - - createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { - set({ error: null }); - const directoryStore = useDirectoryStore.getState(); - const fallbackDirectory = normalizePath(directoryStore.currentDirectory); - const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); - const targetDirectory = vscodeWorkspaceDirectory ?? normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory); - vscodeDebugLog("createSession:start", { title, parentID, targetDirectory, vscodeWorkspaceDirectory }); - - const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; - const previousState = get(); - const existingIds = new Set(previousState.sessions.map((s) => s.id)); - const optimisticSession: Session = { - id: tempId, - title: title || "New session", - parentID: parentID ?? undefined, - directory: targetDirectory ?? null, - projectID: (previousState.sessions[0] as { projectID?: string })?.projectID ?? "", - version: "0.0.0", - time: { - created: Date.now(), - updated: Date.now(), - }, - summary: undefined, - share: undefined, - } as Session; - - set((state) => { - const nextSessions = [optimisticSession, ...state.sessions]; - const nextByDirectory = new Map(state.sessionsByDirectory); - if (targetDirectory) { - const existing = nextByDirectory.get(targetDirectory) ?? []; - nextByDirectory.set(targetDirectory, dedupeSessionsById([optimisticSession, ...existing])); - } - - return { - sessions: nextSessions, - sessionsByDirectory: nextByDirectory, - currentSessionId: tempId, - webUICreatedSessions: new Set([...state.webUICreatedSessions, tempId]), - isLoading: false, - }; - }); - - if (targetDirectory) { - try { - opencodeClient.setDirectory(targetDirectory); - } catch (error) { - console.warn("Failed to sync OpenCode directory after session creation:", error); - } - } - - const replaceOptimistic = (real: Session) => { - const normalizedTarget = targetDirectory ?? null; - const normalizedReal: Session = (normalizedTarget - ? ({ ...real, directory: normalizedTarget } as Session) - : real); - set((state) => { - const updatedSessions = state.sessions.map((item) => (item.id === tempId ? normalizedReal : item)); - - const nextByDirectory = new Map(state.sessionsByDirectory); - if (targetDirectory) { - const existing = nextByDirectory.get(targetDirectory) ?? []; - const replaced = existing.map((item) => (item.id === tempId ? normalizedReal : item)); - nextByDirectory.set(targetDirectory, dedupeSessionsById(replaced)); - } - - return { - sessions: updatedSessions, - sessionsByDirectory: buildSessionsByDirectory(updatedSessions), - currentSessionId: normalizedReal.id, - webUICreatedSessions: new Set([ - ...Array.from(state.webUICreatedSessions).filter((id) => id !== tempId), - normalizedReal.id, - ]), - }; - }); - storeSessionForDirectory(targetDirectory ?? null, normalizedReal.id); - }; - - const pollForSession = async (): Promise => { - const apiClient = opencodeClient.getApiClient(); - const attempts = 20; - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const response = await apiClient.session.list( - targetDirectory ? { directory: targetDirectory } : undefined - ); - const list = Array.isArray(response.data) ? response.data : []; - const candidate = list.find((entry) => { - if (existingIds.has(entry.id)) return false; - if (title && entry.title && entry.title !== title) return false; - return true; - }); - if (candidate) { - return candidate as Session; - } - } catch (pollError) { - console.debug("Session poll attempt failed:", pollError); - } - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - return null; - }; - - try { - const createRequest = () => opencodeClient.createSession({ title, parentID: parentID ?? undefined }); - let session: Session | null = null; - - try { - session = targetDirectory - ? await opencodeClient.withDirectory(targetDirectory, createRequest) - : await createRequest(); - } catch (creationError) { - console.warn("Direct session create failed or timed out, falling back to polling:", creationError); - } - - if (!session) { - session = await pollForSession(); - } - - if (session) { - replaceOptimistic(session); - return session; - } - - set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== tempId), - currentSessionId: previousState.currentSessionId, - webUICreatedSessions: new Set( - Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) - ), - isLoading: false, - error: "Failed to create session", - })); - return null; - } catch (error) { - - set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== tempId), - currentSessionId: previousState.currentSessionId, - webUICreatedSessions: new Set( - Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) - ), - isLoading: false, - error: error instanceof Error ? error.message : "Failed to create session", - })); - return null; - } - }, - - deleteSession: async (id: string, options) => { - set({ isLoading: true, error: null }); - const metadata = get().worktreeMetadata.get(id); - const metadataPath = typeof metadata?.path === 'string' ? metadata.path : null; - const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null; - const snapshot = get(); - const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id); - const requestDirectory = normalizePath(metadataProjectDirectory) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - - let archiveSucceeded = false; - try { - const success = await deleteSessionOnServer(id, requestDirectory); - if (!success) { - set({ - isLoading: false, - error: "Failed to delete session", - }); - return false; - } - - if (metadata && options?.archiveWorktree) { - try { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - }); - archiveSucceeded = true; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete worktree"; - set({ error: message }); - } - } - - let nextCurrentId: string | null = null; - set((state) => { - const filteredSessions = state.sessions.filter((s) => s.id !== id); - const filteredArchivedSessions = state.archivedSessions.filter((s) => s.id !== id); - nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId; - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(id); - const shouldRemoveWorktreeFromLists = Boolean(metadataPath && options?.archiveWorktree && archiveSucceeded); - const nextAvailableWorktrees = shouldRemoveWorktreeFromLists - ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) - : state.availableWorktrees; - const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (shouldRemoveWorktreeFromLists && metadataProjectDirectory) { - const projectKey = normalizePath(metadataProjectDirectory) ?? metadataProjectDirectory; - const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; - nextAvailableWorktreesByProject.set( - projectKey, - projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) - ); - } - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - isLoading: false, - worktreeMetadata: nextMetadata, - availableWorktrees: nextAvailableWorktrees, - availableWorktreesByProject: nextAvailableWorktreesByProject, - }; - }); - - const directoryToStore = normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - storeSessionForDirectory(directoryToStore, nextCurrentId); - - return true; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete session"; - set({ - error: message, - isLoading: false, - }); - return false; - } - }, - - deleteSessions: async ( - ids: string[], - options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean } - ) => { - const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); - if (uniqueIds.length === 0) { - return { deletedIds: [], failedIds: [] }; - } - - const silent = options?.silent === true; - if (!silent) { - set({ isLoading: true, error: null }); - } - const deletedIds: string[] = []; - const failedIds: string[] = []; - const worktreesToArchive = new Map(); - const archivedWorktreePaths = new Set(); - - for (const id of uniqueIds) { - try { - const metadata = get().worktreeMetadata.get(id); - const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); - const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - - if (metadata && options?.archiveWorktree) { - const key = normalizePath(metadata.path) ?? metadata.path; - if (!archivedWorktreePaths.has(key)) { - archivedWorktreePaths.add(key); - worktreesToArchive.set(key, metadata); - } - } - - const success = await deleteSessionOnServer(id, requestDirectory); - if (success) { - deletedIds.push(id); - } else { - failedIds.push(id); - } - } catch { - failedIds.push(id); - } - } - - const archivedWorktrees: Array<{ path: string; projectDirectory: string }> = []; - const archiveFailures: string[] = []; - - if (options?.archiveWorktree && worktreesToArchive.size > 0) { - for (const metadata of worktreesToArchive.values()) { - try { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - }); - archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete worktree"; - archiveFailures.push(message); - } - } - } - - if (archiveFailures.length > 0) { - set({ error: archiveFailures[0] }); - } - - const directoryStore = useDirectoryStore.getState(); - archivedWorktrees.forEach(({ path, projectDirectory }) => { - if (directoryStore.currentDirectory === path) { - directoryStore.setDirectory(projectDirectory, { showOverlay: false }); - } - }); - - const deletedSet = new Set(deletedIds); - const errorMessage = failedIds.length > 0 - ? (failedIds.length === uniqueIds.length ? "Failed to delete sessions" : "Failed to delete some sessions") - : null; - let nextCurrentId: string | null = null; - - set((state) => { - const filteredSessions = state.sessions.filter((session) => !deletedSet.has(session.id)); - const filteredArchivedSessions = state.archivedSessions.filter((session) => !deletedSet.has(session.id)); - if (state.currentSessionId && deletedSet.has(state.currentSessionId)) { - nextCurrentId = null; - } else { - nextCurrentId = state.currentSessionId; - } - - const nextMetadata = new Map(state.worktreeMetadata); - for (const removedId of deletedSet) { - nextMetadata.delete(removedId); - } - - const removedPaths = new Set( - archivedWorktrees - .map((entry) => normalizePath(entry.path)) - .filter((p): p is string => Boolean(p)) - ); - const nextAvailableWorktrees = removedPaths.size > 0 - ? state.availableWorktrees.filter((entry) => !removedPaths.has(normalizePath(entry.path) ?? entry.path)) - : state.availableWorktrees; - - const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (archivedWorktrees.length > 0) { - const removedPathsByProject = archivedWorktrees.reduce>>((accumulator, entry) => { - const projectKey = normalizePath(entry.projectDirectory) ?? entry.projectDirectory; - const pathKey = normalizePath(entry.path) ?? entry.path; - if (!accumulator.has(projectKey)) { - accumulator.set(projectKey, new Set()); - } - accumulator.get(projectKey)?.add(pathKey); - return accumulator; - }, new Map()); - - removedPathsByProject.forEach((paths, projectKey) => { - const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; - const filtered = projectWorktrees.filter( - (entry) => !paths.has(normalizePath(entry.path) ?? entry.path) - ); - nextAvailableWorktreesByProject.set(projectKey, filtered); - }); - } - - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - ...(silent ? {} : { isLoading: false, error: errorMessage }), - worktreeMetadata: nextMetadata, - availableWorktrees: nextAvailableWorktrees, - availableWorktreesByProject: nextAvailableWorktreesByProject, - }; - }); - - const directory = opencodeClient.getDirectory() ?? null; - storeSessionForDirectory(directory, nextCurrentId); - - return { deletedIds, failedIds }; - }, - - archiveSession: async (id: string) => { - const { archivedIds, failedIds } = await get().archiveSessions([id]); - return archivedIds.length === 1 && failedIds.length === 0; - }, - - archiveSessions: async (ids: string[], options?: { silent?: boolean }) => { - const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); - if (uniqueIds.length === 0) { - return { archivedIds: [], failedIds: [] }; - } - - const silent = options?.silent === true; - if (!silent) { - set({ isLoading: true, error: null }); - } - - const archivedIds: string[] = []; - const failedIds: string[] = []; - - for (const id of uniqueIds) { - try { - const metadata = get().worktreeMetadata.get(id); - const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); - const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - const archived = await setSessionArchivedOnServer(id, Date.now(), requestDirectory); - if (!archived) { - failedIds.push(id); - continue; - } - archivedIds.push(id); - } catch { - failedIds.push(id); - } - } - - const archivedSet = new Set(archivedIds); - let nextCurrentId: string | null = null; - const errorMessage = failedIds.length > 0 - ? (failedIds.length === uniqueIds.length ? "Failed to archive sessions" : "Failed to archive some sessions") - : null; - - set((state) => { - if (archivedSet.size === 0) { - return silent ? state : { ...state, isLoading: false, error: errorMessage }; - } - - const archivedRows = state.sessions.filter((session) => archivedSet.has(session.id)).map((session) => ({ - ...session, - time: { - ...session.time, - archived: Date.now(), - }, - } as Session)); - - const remaining = state.sessions.filter((session) => !archivedSet.has(session.id)); - const nextArchivedSessions = dedupeSessionsById([...archivedRows, ...state.archivedSessions]); - - if (state.currentSessionId && archivedSet.has(state.currentSessionId)) { - nextCurrentId = remaining[0]?.id ?? null; - } else { - nextCurrentId = state.currentSessionId; - } - - return { - sessions: remaining, - archivedSessions: nextArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(remaining), - currentSessionId: nextCurrentId, - ...(silent ? {} : { isLoading: false, error: errorMessage }), - }; - }); - - if (!silent && archivedSet.size === 0) { - set({ isLoading: false, error: errorMessage }); - } - - return { archivedIds, failedIds }; - }, - - updateSessionTitle: async (id: string, title: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const metadata = get().worktreeMetadata.get(id); - const updateRequest = () => opencodeClient.updateSession(id, title); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const updatedSession = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, updateRequest) - : await updateRequest(); - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? updatedSession : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to update session title", - }); - } - }, - - shareSession: async (id: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const apiClient = opencodeClient.getApiClient(); - const metadata = get().worktreeMetadata.get(id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const shareRequest = async () => { - const directory = sessionDirectory ?? opencodeClient.getDirectory(); - return apiClient.session.share({ - sessionID: id, - ...(directory ? { directory } : {}) - }); - }; - const response = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, shareRequest) - : await shareRequest(); - - if (response.data) { - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - return response.data; - } - return null; - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to share session", - }); - return null; - } - }, - - unshareSession: async (id: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const apiClient = opencodeClient.getApiClient(); - const metadata = get().worktreeMetadata.get(id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const unshareRequest = async () => { - const directory = sessionDirectory ?? opencodeClient.getDirectory(); - return apiClient.session.unshare({ - sessionID: id, - ...(directory ? { directory } : {}) - }); - }; - const response = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, unshareRequest) - : await unshareRequest(); - - if (response.data) { - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - return response.data; - } - return null; - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to unshare session", - }); - return null; - } - }, - - setCurrentSession: (id: string | null) => { - const prevSessionId = get().currentSessionId; - set({ currentSessionId: id, error: null }); - - // Notify server of view state changes - // This enables server-side needs_attention tracking - if (prevSessionId && prevSessionId !== id) { - // Leaving previous session - fetch(`/api/sessions/${prevSessionId}/unview`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - if (id) { - // Entering new session - fetch(`/api/sessions/${id}/view`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - - // Trigger immediate poll to get latest attention states - // This prevents stale state when switching sessions - triggerSessionStatusPoll(); - - const directory = opencodeClient.getDirectory() ?? null; - storeSessionForDirectory(directory, id); - }, - - clearError: () => { - set({ error: null }); - }, - - getSessionsByDirectory: (directory: string) => { - const normalized = normalizePath(directory) ?? directory; - const { sessionsByDirectory, sessions } = get(); - - const direct = sessionsByDirectory.get(normalized); - if (direct) { - return direct; - } - - return sessions.filter((session) => { - const dir = normalizePath((session as { directory?: string | null }).directory ?? null); - return (dir ?? normalized) === normalized; - }); - }, - - getDirectoryForSession: (sessionId: string) => { - if (!sessionId) { - return null; - } - - const metadata = get().worktreeMetadata.get(sessionId); - if (metadata?.path) { - return normalizePath(metadata.path) ?? metadata.path; - } - - const entry = get().sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - const directory = normalizePath(entry?.directory ?? null); - return directory; - }, - - applySessionMetadata: (sessionId, metadata) => { - if (!sessionId || !metadata) { - return; - } - - set((state) => { - const index = state.sessions.findIndex((session) => session.id === sessionId); - if (index === -1) { - return state; - } - - const existingSession = state.sessions[index]; - const mergedTime = metadata.time - ? { ...existingSession.time, ...metadata.time } - : existingSession.time; - const mergedSummary = - metadata.summary === undefined - ? existingSession.summary - : metadata.summary || undefined; - const mergedShare = - metadata.share === undefined - ? existingSession.share - : metadata.share || undefined; - - const mergedSession: Session = { - ...existingSession, - ...metadata, - time: mergedTime, - summary: mergedSummary, - share: mergedShare, - }; - - const hasChanged = - mergedSession.title !== existingSession.title || - mergedSession.parentID !== existingSession.parentID || - mergedSession.directory !== existingSession.directory || - mergedSession.version !== existingSession.version || - mergedSession.projectID !== existingSession.projectID || - (mergedTime !== existingSession.time && JSON.stringify(mergedTime) !== JSON.stringify(existingSession.time)) || - (mergedSummary !== existingSession.summary && JSON.stringify(mergedSummary ?? null) !== JSON.stringify(existingSession.summary ?? null)) || - (mergedShare !== existingSession.share && JSON.stringify(mergedShare ?? null) !== JSON.stringify(existingSession.share ?? null)); - - const sessions = [...state.sessions]; - sessions[index] = hasChanged ? mergedSession : existingSession; - - return { - sessions, - sessionsByDirectory: buildSessionsByDirectory(sessions), - }; - }); - }, - - isOpenChamberCreatedSession: (sessionId: string) => { - const { webUICreatedSessions } = get(); - return webUICreatedSessions.has(sessionId); - }, - - markSessionAsOpenChamberCreated: (sessionId: string) => { - set((state) => { - const newOpenChamberCreatedSessions = new Set(state.webUICreatedSessions); - newOpenChamberCreatedSessions.add(sessionId); - return { - webUICreatedSessions: newOpenChamberCreatedSessions, - }; - }); - }, - - initializeNewOpenChamberSession: (sessionId: string) => { - const { markSessionAsOpenChamberCreated } = get(); - - markSessionAsOpenChamberCreated(sessionId); - - }, - - setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { - if (!sessionId) { - return; - } - set((state) => { - const next = new Map(state.worktreeMetadata); - if (metadata) { - next.set(sessionId, metadata); - } else { - next.delete(sessionId); - } - return { worktreeMetadata: next }; - }); - }, - - getWorktreeMetadata: (sessionId: string) => { - if (!sessionId) { - return undefined; - } - return get().worktreeMetadata.get(sessionId); - }, - - setSessionDirectory: (sessionId: string, directory: string | null) => { - if (!sessionId) { - return; - } - - const currentSessions = get().sessions; - const targetIndex = currentSessions.findIndex((session) => session.id === sessionId); - if (targetIndex === -1) { - return; - } - - const existingSession = currentSessions[targetIndex]; - const previousDirectory = existingSession.directory ?? null; - const normalizedDirectory = directory ?? undefined; - - if (previousDirectory === (normalizedDirectory ?? null)) { - return; - } - - set((state) => { - const sessions = [...state.sessions]; - const updatedSession = { ...sessions[targetIndex] } as Record; - if (normalizedDirectory !== undefined) { - updatedSession.directory = normalizedDirectory; - } else { - delete updatedSession.directory; - } - sessions[targetIndex] = updatedSession as Session; - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - - if (previousDirectory) { - storeSessionForDirectory(previousDirectory, null); - } - if (directory) { - storeSessionForDirectory(directory, sessionId); - } - - }, - - updateSession: (session: Session) => { - set((state) => { - const index = state.sessions.findIndex((s) => s.id === session.id); - const archivedIndex = state.archivedSessions.findIndex((s) => s.id === session.id); - const isArchived = Boolean(session.time?.archived); - - const nextSessions = index === -1 - ? (isArchived ? state.sessions : [session, ...state.sessions]) - : state.sessions.map((s) => (s.id === session.id ? session : s)); - - const nextArchivedSessions = archivedIndex === -1 - ? (isArchived ? [session, ...state.archivedSessions] : state.archivedSessions) - : state.archivedSessions.map((s) => (s.id === session.id ? session : s)); - - const deduped = dedupeSessionsById(nextSessions.filter((item) => !item.time?.archived)); - const dedupedArchived = dedupeSessionsById(nextArchivedSessions.filter((item) => Boolean(item.time?.archived))); - - return { - sessions: deduped, - archivedSessions: dedupedArchived, - sessionsByDirectory: buildSessionsByDirectory(deduped), - }; - }); - }, - - removeSessionFromStore: (sessionId: string) => { - if (!sessionId) { - return; - } - - set((state) => { - const target = [...state.sessions, ...state.archivedSessions] - .find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - const directory = normalizePath(target?.directory ?? null); - - const filteredSessions = state.sessions.filter((session) => session.id !== sessionId); - const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId); - if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) { - return state; - } - - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(sessionId); - - const nextCurrentId = state.currentSessionId === sessionId ? null : state.currentSessionId; - - if (directory) { - storeSessionForDirectory(directory, null); - } - - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - worktreeMetadata: nextMetadata, - }; - }); - }, - }), - { - name: "session-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - currentSessionId: state.currentSessionId, - sessions: state.sessions, - archivedSessions: state.archivedSessions, - lastLoadedDirectory: state.lastLoadedDirectory, - webUICreatedSessions: Array.from(state.webUICreatedSessions), - worktreeMetadata: Array.from(state.worktreeMetadata.entries()), - availableWorktrees: state.availableWorktrees, - availableWorktreesByProject: Array.from(state.availableWorktreesByProject.entries()), - }), - merge: (persistedState, currentState) => { - const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - - if (!isRecord(persistedState)) { - return currentState; - } - - const persistedSessions = Array.isArray(persistedState.sessions) - ? (persistedState.sessions as Session[]) - : currentState.sessions; - const persistedArchivedSessions = Array.isArray(persistedState.archivedSessions) - ? (persistedState.archivedSessions as Session[]) - : currentState.archivedSessions; - - const persistedCurrentSessionId = - typeof persistedState.currentSessionId === "string" || persistedState.currentSessionId === null - ? (persistedState.currentSessionId as string | null) - : currentState.currentSessionId; - - const webUiSessionsArray = Array.isArray(persistedState.webUICreatedSessions) - ? (persistedState.webUICreatedSessions as string[]) - : []; - - const persistedWorktreeEntries = Array.isArray(persistedState.worktreeMetadata) - ? (persistedState.worktreeMetadata as Array<[string, WorktreeMetadata]>) - : []; - - const persistedAvailableWorktrees = Array.isArray(persistedState.availableWorktrees) - ? (persistedState.availableWorktrees as WorktreeMetadata[]) - : currentState.availableWorktrees; - - const persistedWorktreesByProjectEntries = Array.isArray(persistedState.availableWorktreesByProject) - ? (persistedState.availableWorktreesByProject as Array<[string, WorktreeMetadata[]]>) - : []; - const persistedWorktreesByProject = new Map(persistedWorktreesByProjectEntries); - - const lastLoadedDirectory = - typeof persistedState.lastLoadedDirectory === "string" - ? persistedState.lastLoadedDirectory - : currentState.lastLoadedDirectory ?? null; - - const mergedSessions = dedupeSessionsById(persistedSessions); - const mergedArchivedSessions = dedupeSessionsById(persistedArchivedSessions); - - const mergedResult = { - ...currentState, - ...persistedState, - sessions: mergedSessions, - archivedSessions: mergedArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(mergedSessions), - currentSessionId: persistedCurrentSessionId, - webUICreatedSessions: new Set(webUiSessionsArray), - worktreeMetadata: new Map(persistedWorktreeEntries), - availableWorktrees: persistedAvailableWorktrees, - availableWorktreesByProject: persistedWorktreesByProject.size > 0 - ? persistedWorktreesByProject - : currentState.availableWorktreesByProject, - lastLoadedDirectory, - }; - return mergedResult; - }, - } - ), - { - name: "session-store", - } - ) -); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 9f5b13bd..d4716cde 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -187,15 +187,7 @@ export interface SessionStore { { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number } >; - // Server-authoritative session attention state - // Tracks which sessions need user attention based on server-side logic - sessionAttentionStates: Map; + // sessionAttentionStates removed — replaced by notification-store userSummaryTitles: Map; @@ -263,7 +255,7 @@ export interface SessionStore { clearError: () => void; getSessionsByDirectory: (directory: string) => Session[]; getDirectoryForSession: (sessionId: string) => string | null; - getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; + getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null; getCurrentAgent: (sessionId: string) => string | undefined; syncMessages: ( sessionId: string, @@ -291,8 +283,6 @@ export interface SessionStore { saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void; getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined; - - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise>; isOpenChamberCreatedSession: (sessionId: string) => boolean; diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index eeca5eea..31188324 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -1,103 +1,16 @@ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; -import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { listProjectWorktrees, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; -import { useSessionStore } from './useSessionStore'; +import { deleteSessionInDirectory } from '@/sync/session-actions'; +import { retry } from '@/sync/retry'; import type { WorktreeMetadata } from '@/types/worktree'; import type { Session } from '@opencode-ai/sdk/v2'; - -const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => { - const projectsState = useProjectsStore.getState(); - const activeProjectId = projectsState.activeProjectId; - const activeProjectPath = activeProjectId - ? projectsState.projects.find((project) => project.id === activeProjectId)?.path - : undefined; - - if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) { - return activeProjectPath; - } - - return currentDirectory ? normalize(currentDirectory) : null; -}; - -/** - * Agent group session parsed from OpenCode session titles. - * Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index` - * Model can contain `/` for creator/model format (e.g., `anthropic/claude-opus-4-5`) - * - * Examples: - * - `feature/opencode/claude-sonnet-4-5` → group="feature", provider="opencode", model="claude-sonnet-4-5" - * - `feature/opencode/claude-sonnet-4-1/2` → group="feature", provider="opencode", model="claude-sonnet-4-1", index=2 - * - `feature/openrouter/anthropic/claude-opus-4-5` → group="feature", provider="openrouter", model="anthropic/claude-opus-4-5" - */ -export interface AgentGroupSession { - /** OpenCode session ID */ - id: string; - /** Full worktree path (from session.directory) */ - path: string; - /** Provider ID extracted from title */ - providerId: string; - /** Model ID extracted from title (may contain / for creator/model format) */ - modelId: string; - /** Instance number for duplicate model selections (default: 1) */ - instanceNumber: number; - /** Branch name associated with this worktree */ - branch: string; - /** Display label for the model */ - displayLabel: string; - /** Full worktree metadata */ - worktreeMetadata?: WorktreeMetadata; -} - -export interface AgentGroup { - /** Group name (e.g., "agent-manager-2", "contributing") */ - name: string; - /** Sessions within this group (one per model instance) */ - sessions: AgentGroupSession[]; - /** Timestamp of last activity (most recent session update) */ - lastActive: number; - /** Total session count */ - sessionCount: number; -} - -interface AgentGroupsState { - /** All discovered agent groups from session titles */ - groups: AgentGroup[]; - /** Currently selected group name */ - selectedGroupName: string | null; - /** Currently selected session ID within the group */ - selectedSessionId: string | null; - /** Loading state */ - isLoading: boolean; - /** Error message */ - error: string | null; -} - -interface AgentGroupsActions { - /** Load/refresh agent groups from OpenCode sessions */ - loadGroups: () => Promise; - /** Select a group */ - selectGroup: (groupName: string | null) => void; - /** Select a session within the current group */ - selectSession: (sessionId: string | null) => void; - /** Delete the entire group (all worktrees + sessions in those worktrees). */ - deleteGroup: (groupName: string) => Promise; - /** Delete a single worktree within a group (and all sessions in that worktree). */ - deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise; - /** Keep one worktree and remove all others in the group. */ - keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise; - /** Get the currently selected group */ - getSelectedGroup: () => AgentGroup | null; - /** Get the currently selected session */ - getSelectedSession: () => AgentGroupSession | null; - /** Clear error */ - clearError: () => void; -} - -type AgentGroupsStore = AgentGroupsState & AgentGroupsActions; +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- const normalize = (value: string): string => { if (!value) return ''; @@ -106,632 +19,338 @@ const normalize = (value: string): string => { return replaced.replace(/\/+$/, ''); }; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- -const startsWithDirectory = (candidate: string, root: string): boolean => { - const normalizedCandidate = normalize(candidate); - const normalizedRoot = normalize(root); - if (!normalizedCandidate || !normalizedRoot) { - return false; - } - if (normalizedCandidate === normalizedRoot) { - return true; - } - const prefix = normalizedRoot === '/' ? '/' : `${normalizedRoot}/`; - return normalizedCandidate.startsWith(prefix); -}; +export interface AgentGroupSession { + id: string; + path: string; + providerId: string; + modelId: string; + instanceNumber: number; + branch: string; + displayLabel: string; + worktreeMetadata?: WorktreeMetadata; +} -const resolveCanonicalDirectory = async ( - apiClient: ReturnType, - directory: string -): Promise => { - const normalized = normalize(directory); - if (!normalized) { - return normalized; - } - try { - const response = await apiClient.path.get({ directory: normalized }); - const canonical = normalize((response.data as { directory?: string | null } | null)?.directory ?? ''); - return canonical || normalized; - } catch { - return normalized; - } -}; +export interface AgentGroup { + name: string; + sessions: AgentGroupSession[]; + lastActive: number; + sessionCount: number; +} -const listSessionsForDirectory = async ( - apiClient: ReturnType, - directory: string -): Promise => { - const normalized = normalize(directory); - if (!normalized) { - return []; - } +export interface DeleteAgentGroupResult { + failedIds: string[]; + failedWorktreePaths: string[]; +} - const canonical = await resolveCanonicalDirectory(apiClient, normalized); +// --------------------------------------------------------------------------- +// parseSessionTitle +// --------------------------------------------------------------------------- - const filterToDirectory = (sessions: Session[]) => { - return sessions.filter((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - if (!dir) return false; - return startsWithDirectory(dir, normalized) || (canonical !== normalized && startsWithDirectory(dir, canonical)); - }); - }; - - const attemptList = async (dir: string) => { - const response = await apiClient.session.list({ directory: dir }); - return Array.isArray(response.data) ? response.data : []; - }; - - try { - const list = filterToDirectory(await attemptList(normalized)); - if (list.length > 0) { - return list; - } - } catch { - // ignore - } - - if (canonical && canonical !== normalized) { - try { - const list = filterToDirectory(await attemptList(canonical)); - if (list.length > 0) { - return list; - } - } catch { - // ignore - } - } - - try { - const global = await apiClient.session.list(undefined); - const list = Array.isArray(global.data) ? global.data : []; - return filterToDirectory(list); - } catch { - return []; - } -}; - -const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: string): Promise> => { - const map = new Map(); - - group.sessions.forEach((session) => { - if (session.worktreeMetadata) { - map.set(normalize(session.path), session.worktreeMetadata); - } - }); - - const missingPaths = Array.from(new Set(group.sessions.map((session) => normalize(session.path)))) - .filter(Boolean) - .filter((path) => !map.has(path)); - - if (missingPaths.length === 0) { - return map; - } - - try { - const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory }); - const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta])); - missingPaths.forEach((path) => { - const info = infoByPath.get(path); - if (info) { - map.set(path, info); - } - }); - } catch { - // ignore - } - - return map; -}; - -const collectDeleteCandidates = async (params: { - apiClient: ReturnType; - group: AgentGroup; - projectDirectory: string; - worktreePaths: string[]; -}): Promise> => { - const { apiClient, group, projectDirectory, worktreePaths } = params; - const metadataByPath = await buildWorktreeMetadataByPath(group, projectDirectory); - const sessionStore = useSessionStore.getState(); - - const uniqueWorktreePaths = Array.from(new Set(worktreePaths.map((path) => normalize(path)).filter(Boolean))); - const concurrency = 5; - let index = 0; - - const results: Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }> = []; - - const worker = async () => { - while (index < uniqueWorktreePaths.length) { - const current = uniqueWorktreePaths[index]; - index += 1; - - const sessionsInGroup = group.sessions.filter((session) => normalize(session.path) === current).map((session) => session.id); - const cached = sessionStore.getSessionsByDirectory(current); - const cachedIds = Array.isArray(cached) ? cached.map((session) => session.id) : []; - - // Prefer the session store cache (already directory-partitioned). If empty, fall back to direct API listing. - let listedIds: string[] = []; - if (cachedIds.length === 0) { - try { - const listed = await listSessionsForDirectory(apiClient, current); - listedIds = listed.map((session) => session.id); - } catch { - listedIds = []; - } - } - - const ids = Array.from(new Set([...cachedIds, ...listedIds, ...sessionsInGroup].filter(Boolean))); - results.push({ - worktreePath: current, - sessionIds: ids, - metadata: metadataByPath.get(current), - }); - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, uniqueWorktreePaths.length) }, worker)); - return results; -}; - -const deleteGroupWorktreeSessions = async (params: { - group: AgentGroup; - projectDirectory: string; - worktreePaths: string[]; -}) => { - const apiClient = opencodeClient.getApiClient(); - const candidates = await collectDeleteCandidates({ - apiClient, - group: params.group, - projectDirectory: params.projectDirectory, - worktreePaths: params.worktreePaths, - }); - - const sessionStore = useSessionStore.getState(); - const ids = new Set(); - - candidates.forEach(({ worktreePath, sessionIds, metadata }) => { - sessionIds.forEach((id) => { - ids.add(id); - if (metadata) { - sessionStore.setWorktreeMetadata(id, metadata); - sessionStore.setSessionDirectory(id, worktreePath); - } - }); - }); - - if (ids.size === 0) { - return { failedIds: [] as string[] }; - } - - return sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); -}; - -/** - * Parse a session title to extract group, provider, model, and index. - * Title format: groupSlug/provider/model[/index] - * - * The groupSlug is always the first segment (cannot contain `/` as it's sanitized). - * The provider is always the second segment. - * Everything after the provider (excluding numeric index) is the model. - * Model can contain `/` for creator/model format. - * - * Examples: - * - "feature/opencode/claude-sonnet-4-5" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-5", index: 1 } - * - "feature/opencode/claude-sonnet-4-1/2" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-1", index: 2 } - * - "feature/openrouter/anthropic/claude-opus-4-5" → { groupSlug: "feature", provider: "openrouter", model: "anthropic/claude-opus-4-5", index: 1 } - * - "my-task/anthropic/claude-sonnet-4/1" → { groupSlug: "my-task", provider: "anthropic", model: "claude-sonnet-4", index: 1 } - */ -function parseSessionTitle(title: string | undefined): { +export function parseSessionTitle(title: string | undefined): { groupSlug: string; provider: string; model: string; index: number; } | null { if (!title) return null; - const parts = title.split('/'); if (parts.length < 3) return null; - // First part is always groupSlug (cannot contain / or spaces as it's sanitized by toGitSafeSlug) const groupSlug = parts[0]; if (!groupSlug || groupSlug.includes(' ')) return null; - // Second part is always provider const provider = parts[1]; if (!provider) return null; - // Check if last part is a numeric index const lastPart = parts[parts.length - 1]; const lastPartNum = parseInt(lastPart, 10); const hasIndex = parts.length >= 4 && !isNaN(lastPartNum) && String(lastPartNum) === lastPart; - // Model is everything from parts[2] to end (excluding index if present) - const modelParts = hasIndex - ? parts.slice(2, -1) - : parts.slice(2); + const modelParts = hasIndex ? parts.slice(2, -1) : parts.slice(2); + if (modelParts.length === 0) return null; - // Must have at least one model part - if (modelParts.length === 0) { - return null; - } - - const model = modelParts.join('/'); - - return { - groupSlug, - provider, - model, - index: hasIndex ? lastPartNum : 1, - }; + return { groupSlug, provider, model: modelParts.join('/'), index: hasIndex ? lastPartNum : 1 }; } -export const useAgentGroupsStore = create()( - devtools( - (set, get) => ({ - groups: [], - selectedGroupName: null, - selectedSessionId: null, - isLoading: false, - error: null, +// --------------------------------------------------------------------------- +// resolveProjectRef +// --------------------------------------------------------------------------- - loadGroups: async () => { - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); +function resolveProjectRef(): { id: string; path: string } | null { + const currentDirectory = useDirectoryStore.getState().currentDirectory; + const projectsState = useProjectsStore.getState(); + const activeProjectId = projectsState.activeProjectId; + const activeProjectPath = activeProjectId + ? projectsState.projects.find((p) => p.id === activeProjectId)?.path + : undefined; - if (!projectDirectory) { - set({ groups: [], isLoading: false, error: 'No project directory selected' }); + const raw = (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) + ? activeProjectPath + : currentDirectory; + + if (!raw) return null; + const path = normalize(raw); + if (!path) return null; + + const entry = projectsState.projects.find((p) => normalize(p.path) === path); + return { id: entry?.id ?? `path:${path}`, path }; +} + +function resolveProjectRefForWorktree(session: AgentGroupSession): ProjectRef | null { + const projectsState = useProjectsStore.getState(); + const projectPath = normalize(session.worktreeMetadata?.projectDirectory ?? ''); + if (projectPath) { + const project = projectsState.projects.find((entry) => normalize(entry.path) === projectPath); + return { id: project?.id ?? `path:${projectPath}`, path: projectPath }; + } + return resolveProjectRef(); +} + +// --------------------------------------------------------------------------- +// buildGroups — turns raw sessions + worktree metadata into AgentGroup[] +// --------------------------------------------------------------------------- + +function buildGroups( + sessions: Session[], + metaByPath: Map, +): AgentGroup[] { + const map = new Map(); + + for (const session of sessions) { + const parsed = parseSessionTitle(session.title); + if (!parsed) continue; + + const sessionPath = normalize(session.directory ?? ''); + const meta = metaByPath.get(sessionPath); + + const entry: AgentGroupSession = { + id: session.id, + path: sessionPath, + providerId: parsed.provider, + modelId: parsed.model, + instanceNumber: parsed.index, + branch: meta?.branch ?? '', + displayLabel: `${parsed.provider}/${parsed.model}`, + worktreeMetadata: meta, + }; + + const existing = map.get(parsed.groupSlug); + if (existing) existing.push(entry); + else map.set(parsed.groupSlug, [entry]); + } + + const groups: AgentGroup[] = []; + for (const [name, groupSessions] of map) { + const lastActive = groupSessions.reduce((max, gs) => { + const raw = sessions.find((s) => s.id === gs.id); + const t = (raw as { time?: { updated?: number | null } } | undefined)?.time?.updated ?? 0; + return Math.max(max, typeof t === 'number' ? t : 0); + }, 0); + + groupSessions.sort((a, b) => { + const p = a.providerId.localeCompare(b.providerId); + if (p !== 0) return p; + const m = a.modelId.localeCompare(b.modelId); + if (m !== 0) return m; + return a.instanceNumber - b.instanceNumber; + }); + + groups.push({ name, sessions: groupSessions, lastActive: lastActive || Date.now(), sessionCount: groupSessions.length }); + } + + groups.sort((a, b) => a.name.localeCompare(b.name)); + return groups; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +interface AgentGroupsState { + groups: AgentGroup[]; + selectedGroupName: string | null; + selectedSessionId: string | null; + isLoading: boolean; + error: string | null; +} + +interface AgentGroupsActions { + /** List worktrees, fetch sessions per worktree, build groups. */ + loadGroups: () => Promise; + selectGroup: (groupName: string | null) => void; + selectSession: (sessionId: string | null) => void; + deleteGroupSessions: (sessions: AgentGroupSession[], options?: { removeWorktrees?: boolean }) => Promise; + clearError: () => void; +} + +type Store = AgentGroupsState & AgentGroupsActions; + +export const useAgentGroupsStore = create()( + (set, get) => ({ + groups: [], + selectedGroupName: null, + selectedSessionId: null, + isLoading: false, + error: null, + + loadGroups: async () => { + const projectRef = resolveProjectRef(); + if (!projectRef) { + set({ groups: [], isLoading: false, error: 'No project directory' }); + return; + } + + set({ isLoading: true, error: null }); + + try { + // 1. List worktrees (already cached 30s by worktreeManager) + const worktrees = await listProjectWorktrees(projectRef); + const metaByPath = new Map(); + const dirs: string[] = []; + for (const meta of worktrees) { + if (meta?.path) { + const key = normalize(meta.path); + dirs.push(key); + metaByPath.set(key, meta); + } + } + + if (dirs.length === 0) { + set({ groups: [], isLoading: false }); return; } - const normalizedProject = normalize(projectDirectory); + // 2. Fetch sessions for each worktree directory (parallel, max 5) + const api = opencodeClient.getApiClient(); + const allSessions: Session[] = []; + const failedDirectories = new Set(); - const projectsState = useProjectsStore.getState(); - const projectEntry = projectsState.projects.find((p) => normalize(p.path) === normalizedProject); - const projectRef = { - id: projectEntry?.id ?? `path:${normalizedProject}`, - path: normalizedProject, + const fetchDir = async (dir: string) => { + try { + const res = await retry(async () => { + const result = await api.session.list({ directory: dir }); + if ((result as { error?: unknown }).error) { + throw new Error(`session.list failed for ${dir}: ${String((result as { error?: unknown }).error)}`); + } + return result; + }); + const list = Array.isArray(res.data) ? res.data : []; + for (const s of list) if (s?.id) allSessions.push(s); + } catch { + failedDirectories.add(dir); + } }; - const previousGroups = get().groups; - set({ isLoading: true, error: null }); - - try { - const apiClient = opencodeClient.getApiClient(); - const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject); - const canonicalRef = canonicalProject && canonicalProject !== normalizedProject - ? { ...projectRef, path: canonicalProject } - : null; - - const managedWorktrees = await listProjectWorktrees(projectRef).catch(() => []); - const managedWorktreesCanonical = canonicalRef - ? await listProjectWorktrees(canonicalRef).catch(() => []) - : []; - - const worktreeDirectorySet = new Set(); - const worktreeMetadataMap = new Map(); - [...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => { - if (meta?.path) { - const key = normalize(meta.path); - worktreeDirectorySet.add(key); - if (!worktreeMetadataMap.has(key)) { - worktreeMetadataMap.set(key, meta); - } - } - }); - - const fetchCandidateSessions = async (): Promise => { - try { - const scoped = await apiClient.session.list({ directory: normalizedProject }); - const list = Array.isArray(scoped.data) ? scoped.data : []; - if (list.some((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - return dir ? worktreeDirectorySet.has(dir) : false; - })) { - return list; - } - } catch { - // ignore and fall back to global list - } - - const global = await apiClient.session.list(undefined); - return Array.isArray(global.data) ? global.data : []; - }; - - const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise => { - const sessionsMap = new Map(); - const concurrency = 5; - let index = 0; - - const worker = async () => { - while (index < directories.length) { - const current = directories[index]; - index += 1; - const normalizedDir = normalize(current); - if (!normalizedDir) continue; - - try { - const sessions = await listSessionsForDirectory(apiClient, normalizedDir); - sessions.forEach((session) => sessionsMap.set(session.id, session)); - } catch (err) { - console.debug('Failed to fetch sessions from worktree:', normalizedDir, err); - } - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, directories.length) }, worker)); - return Array.from(sessionsMap.values()); - }; - - const candidateSessions = await fetchCandidateSessions(); - let allSessions = candidateSessions.filter((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - if (!dir) { - return false; - } - return worktreeDirectorySet.has(dir); - }); - - // Some OpenCode builds do not return sessions across directories in the global list. - // If we didn't discover any group sessions, fall back to querying each worktree directory directly. - if (allSessions.length === 0) { - const candidates = new Set(); - - // 1) Known worktree directories for this project - worktreeDirectorySet.forEach((dir) => candidates.add(dir)); - - if (candidates.size > 0) { - allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates)); - } + // Simple concurrency limiter + let idx = 0; + const worker = async () => { + while (idx < dirs.length) { + const i = idx++; + await fetchDir(dirs[i]); } + }; + await Promise.all(Array.from({ length: Math.min(5, dirs.length) }, () => worker())); - const sessionUpdatedAtById = new Map(); - for (const session of allSessions) { - const updatedAt = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; - sessionUpdatedAtById.set(session.id, typeof updatedAt === 'number' ? updatedAt : 0); - } - - // Parse sessions and group by groupSlug - const groupsMap = new Map(); - - for (const session of allSessions) { - const parsed = parseSessionTitle(session.title); - if (!parsed) continue; // Skip sessions without valid agent group title - - const sessionPath = normalize(session.directory); - const worktreeInfo = worktreeMetadataMap.get(sessionPath); - - const agentSession: AgentGroupSession = { - id: session.id, - path: sessionPath, - providerId: parsed.provider, - modelId: parsed.model, - instanceNumber: parsed.index, - branch: worktreeInfo?.branch ?? '', - displayLabel: `${parsed.provider}/${parsed.model}`, - worktreeMetadata: worktreeInfo, - }; - - const existing = groupsMap.get(parsed.groupSlug); - if (existing) { - existing.push(agentSession); - } else { - groupsMap.set(parsed.groupSlug, [agentSession]); - } - } - - // Convert map to array and sort - const groups: AgentGroup[] = Array.from(groupsMap.entries()).map( - ([name, sessions]) => { - // Find the most recent session update time for lastActive - const lastActive = sessions.reduce((max, s) => { - const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0; - return Math.max(max, updatedTime); - }, 0); - - return { - name, - sessions: sessions.sort((a, b) => { - // Sort by provider, then model, then instance - const providerCmp = a.providerId.localeCompare(b.providerId); - if (providerCmp !== 0) return providerCmp; - const modelCmp = a.modelId.localeCompare(b.modelId); - if (modelCmp !== 0) return modelCmp; - return a.instanceNumber - b.instanceNumber; - }), - lastActive: lastActive || Date.now(), - sessionCount: sessions.length, - }; - } - ); - - // Sort groups by name - groups.sort((a, b) => a.name.localeCompare(b.name)); - - set({ groups, isLoading: false, error: null }); - } catch (err) { - console.error('Failed to load agent groups:', err); - // Preserve existing groups on error to avoid UI flickering - set({ - groups: previousGroups.length > 0 ? previousGroups : [], - isLoading: false, - error: err instanceof Error ? err.message : 'Failed to load agent groups', - }); - } - }, - - selectGroup: (groupName) => { - const { groups } = get(); - const group = groups.find((g) => g.name === groupName); - + // 3. Build groups + const groups = buildGroups(allSessions, metaByPath); set({ - selectedGroupName: groupName, - // Auto-select first session when selecting a group - selectedSessionId: group?.sessions[0]?.id ?? null, + groups, + isLoading: false, + error: failedDirectories.size > 0 ? `Failed to load sessions for ${failedDirectories.size} worktree${failedDirectories.size === 1 ? '' : 's'}` : null, }); - }, + } catch (err) { + set({ + groups: get().groups, // preserve on error + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load groups', + }); + } + }, - selectSession: (sessionId) => { - set({ selectedSessionId: sessionId }); - }, + selectGroup: (groupName) => { + if (!groupName) { + set({ selectedGroupName: null, selectedSessionId: null }); + return; + } + const group = get().groups.find((g) => g.name === groupName); + set({ + selectedGroupName: groupName, + selectedSessionId: group?.sessions[0]?.id ?? null, + }); + }, - deleteGroup: async (groupName) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; + selectSession: (sessionId) => set({ selectedSessionId: sessionId }), + + deleteGroupSessions: async (sessions, options) => { + const failedIds: string[] = []; + const failedWorktreePaths: string[] = []; + const removeWorktrees = options?.removeWorktrees === true; + const deletedIds = new Set(); + + for (const s of sessions) { + if (!s.path) continue; + const ok = await deleteSessionInDirectory(s.id, s.path); + if (!ok) failedIds.push(s.id); + else deletedIds.add(s.id); + } + + if (removeWorktrees) { + const worktreesByPath = new Map(); + for (const session of sessions) { + const path = normalize(session.path); + if (!path) continue; + const existing = worktreesByPath.get(path); + if (existing) existing.push(session); + else worktreesByPath.set(path, [session]); } - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } - - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: group.sessions.map((s) => s.path), - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); + for (const [path, pathSessions] of worktreesByPath) { + if (pathSessions.some((session) => failedIds.includes(session.id))) { + failedWorktreePaths.push(path); + continue; } - if (get().selectedGroupName === groupName) { - set({ selectedGroupName: null, selectedSessionId: null }); + const source = pathSessions.find((session) => session.worktreeMetadata)?.worktreeMetadata ?? pathSessions[0]?.worktreeMetadata; + const projectRef = pathSessions.map(resolveProjectRefForWorktree).find((value): value is ProjectRef => value !== null) ?? null; + if (!source || !projectRef) { + failedWorktreePaths.push(path); + continue; } - await get().loadGroups(); - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to delete group' }); - return false; - } finally { - set({ isLoading: false }); - } - }, - - deleteGroupWorktree: async (groupName, worktreePath) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; - } - const normalizedWorktreePath = normalize(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } - - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: [normalizedWorktreePath], - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); - } - - await get().loadGroups(); - - const updated = get().groups.find((g) => g.name === groupName); - if (!updated) { - if (get().selectedGroupName === groupName) { - set({ selectedGroupName: null, selectedSessionId: null }); + try { + await removeProjectWorktree(projectRef, source, { deleteLocalBranch: true }); + const directoryStore = useDirectoryStore.getState(); + if (normalize(directoryStore.currentDirectory) === path) { + directoryStore.setDirectory(projectRef.path, { showOverlay: false }); } - return failedIds.length === 0; + } catch { + failedWorktreePaths.push(path); } - - if (get().selectedGroupName === groupName) { - const currentSelected = get().selectedSessionId; - const remainingIds = new Set(updated.sessions.map((s) => s.id)); - if (!currentSelected || !remainingIds.has(currentSelected)) { - set({ selectedSessionId: updated.sessions[0]?.id ?? null }); - } - } - - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to delete worktree' }); - return false; - } finally { - set({ isLoading: false }); } - }, + } - keepOnlyGroupWorktree: async (groupName, keepWorktreePath) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; - } - const keepPath = normalize(keepWorktreePath); - if (!keepPath) { - return false; + // Clear selection if needed + const { selectedSessionId, selectedGroupName } = get(); + if (selectedSessionId && deletedIds.has(selectedSessionId)) { + set({ selectedSessionId: null }); + } + if (selectedGroupName) { + const group = get().groups.find((g) => g.name === selectedGroupName); + if (group && group.sessions.every((s) => deletedIds.has(s.id))) { + set({ selectedGroupName: null, selectedSessionId: null }); } + } - const worktreePaths = Array.from(new Set(group.sessions.map((s) => normalize(s.path)).filter(Boolean))); - const toDelete = worktreePaths.filter((path) => path !== keepPath); - if (toDelete.length === 0) { - return true; - } + // Refresh groups after delete + void get().loadGroups(); - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } + return { failedIds, failedWorktreePaths }; + }, - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: toDelete, - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); - } - - await get().loadGroups(); - if (get().selectedGroupName === groupName) { - const updated = get().groups.find((g) => g.name === groupName); - const keepSession = updated?.sessions.find((s) => normalize(s.path) === keepPath) ?? updated?.sessions[0] ?? null; - set({ selectedSessionId: keepSession?.id ?? null }); - } - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to remove other worktrees' }); - return false; - } finally { - set({ isLoading: false }); - } - }, - - getSelectedGroup: () => { - const { groups, selectedGroupName } = get(); - if (!selectedGroupName) return null; - return groups.find((g) => g.name === selectedGroupName) ?? null; - }, - - getSelectedSession: () => { - const { selectedSessionId } = get(); - const group = get().getSelectedGroup(); - if (!group || !selectedSessionId) return null; - return group.sessions.find((s) => s.id === selectedSessionId) ?? null; - }, - - clearError: () => { - set({ error: null }); - }, - }), - { name: 'agent-groups-store' } - ) + clearError: () => set({ error: null }), + }), ); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index e4cfc296..e965bec0 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -6,8 +6,9 @@ import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; import { getSafeStorage } from "./utils/safeStorage"; -import type { SessionStore } from "./types/sessionTypes"; import { filterVisibleAgents } from "./useAgentsStore"; +import { useSessionUIStore } from "@/sync/session-ui-store"; +import { useSelectionStore } from "@/sync/selection-store"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { updateDesktopSettings } from "@/lib/persistence"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; @@ -529,10 +530,13 @@ interface ConfigStore { declare global { interface Window { __zustand_config_store__?: UseBoundStore>; - __zustand_session_store__?: UseBoundStore>; } } +// In-flight dedup: prevent concurrent duplicate loadProviders/loadAgents calls for the same directory +const _inFlightProviders = new Map>(); +const _inFlightAgents = new Map>(); + export const useConfigStore = create()( devtools( persist( @@ -724,6 +728,12 @@ export const useConfigStore = create()( loadProviders: async (options) => { const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + + // Dedup: if a load is already in-flight for this directory, reuse it + const existing = _inFlightProviders.get(directoryKey); + if (existing) return existing; + + const promise = (async () => { const existingSnapshot = get().directoryScoped[directoryKey]; const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []); const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {}); @@ -872,6 +882,10 @@ export const useConfigStore = create()( return nextState; }); + })().finally(() => _inFlightProviders.delete(directoryKey)); + + _inFlightProviders.set(directoryKey, promise); + return promise; }, setProvider: (providerId: string) => { @@ -1082,6 +1096,12 @@ export const useConfigStore = create()( loadAgents: async (options) => { const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + + // Dedup: if a load is already in-flight for this directory, reuse it + const existing = _inFlightAgents.get(directoryKey); + if (existing) return existing; + + const promise = (async (): Promise => { const existingSnapshot = get().directoryScoped[directoryKey]; const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []); let lastError: unknown = null; @@ -1392,6 +1412,10 @@ export const useConfigStore = create()( }); return false; + })().finally(() => _inFlightAgents.delete(directoryKey)); + + _inFlightAgents.set(directoryKey, promise); + return promise; }, setAgent: (agentName: string | undefined) => { @@ -1424,44 +1448,29 @@ export const useConfigStore = create()( }; }); - if (agentName && typeof window !== "undefined") { + if (agentName) { + const { currentSessionId } = useSessionUIStore.getState(); + const selState = useSelectionStore.getState(); - const sessionStore = window.__zustand_session_store__; - if (sessionStore) { - const sessionState = sessionStore.getState(); - const { currentSessionId, isOpenChamberCreatedSession, initializeNewOpenChamberSession, getAgentModelForSession } = sessionState; + if (currentSessionId) { + selState.saveSessionAgentSelection(currentSessionId, agentName); + } - if (currentSessionId) { - - sessionStore.setState((state) => { - const newAgentContext = new Map(state.currentAgentContext); - newAgentContext.set(currentSessionId, agentName); - return { currentAgentContext: newAgentContext }; - }); - } - - if (currentSessionId && isOpenChamberCreatedSession(currentSessionId)) { - const existingAgentModel = getAgentModelForSession(currentSessionId, agentName); - if (!existingAgentModel) { - - initializeNewOpenChamberSession(currentSessionId, agents); - } + if (currentSessionId && useSessionUIStore.getState().isOpenChamberCreatedSession(currentSessionId)) { + const existingAgentModel = selState.getAgentModelForSession(currentSessionId, agentName); + if (!existingAgentModel) { + useSessionUIStore.getState().initializeNewOpenChamberSession(currentSessionId, agents); } } } - if (agentName && typeof window !== "undefined") { - const sessionStore = window.__zustand_session_store__; - if (sessionStore?.getState) { - const { currentSessionId, getAgentModelForSession } = sessionStore.getState(); + if (agentName) { + const { currentSessionId } = useSessionUIStore.getState(); - if (currentSessionId) { - const existingAgentModel = getAgentModelForSession(currentSessionId, agentName); - - if (existingAgentModel) { - - return; - } + if (currentSessionId) { + const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); + if (existingAgentModel) { + return; } } @@ -1792,9 +1801,7 @@ export const useConfigStore = create()( return undefined; } - const derived = deriveModelMetadata(providerId, model); - set({ modelsMetadata: new Map(modelsMetadata).set(key, derived) }); - return derived; + return deriveModelMetadata(providerId, model); }, getVisibleAgents: () => { const { agents } = get(); diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 811fd797..1cbc24ad 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -33,6 +33,9 @@ const fetchStatus = async ( return payload; }; +// In-flight dedup for refreshStatus +let _inFlightAuthRefresh: Promise | null = null; + export const useGitHubAuthStore = create((set, get) => ({ status: null, isLoading: false, @@ -44,19 +47,25 @@ export const useGitHubAuthStore = create((set, get) => ({ return status; } + if (_inFlightAuthRefresh) return _inFlightAuthRefresh; + set({ isLoading: true }); - try { - const payload = await fetchStatus(runtimeGitHub); - set({ status: payload, isLoading: false, hasChecked: true }); - return payload; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - set({ - status: { connected: false, error: message }, - isLoading: false, - hasChecked: true, - }); - return null; - } + _inFlightAuthRefresh = (async () => { + try { + const payload = await fetchStatus(runtimeGitHub); + set({ status: payload, isLoading: false, hasChecked: true }); + return payload; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set({ + status: { connected: false, error: message }, + isLoading: false, + hasChecked: true, + }); + return null; + } + })().finally(() => { _inFlightAuthRefresh = null; }); + + return _inFlightAuthRefresh; }, })); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index b626592b..4544ffd0 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -16,8 +16,9 @@ const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; const DIFF_PREFETCH_MAX_FILES = 25; const DIFF_PREFETCH_FOCUS_MAX_FILES = 40; -const DIFF_PREFETCH_CONCURRENCY = 4; +const DIFF_PREFETCH_CONCURRENCY = 2; const DIFF_PREFETCH_TIMEOUT_MS = 15000; +const DIFF_PREFETCH_LARGE_FILE_THRESHOLD = 500; // skip prefetch for files with >500 changed lines const RECENT_DIRECTORIES_LIMIT = 3; // Diff cache limits to prevent memory bloat with many modified files @@ -57,7 +58,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean }) => Promise; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; @@ -87,7 +88,7 @@ interface GitFileDiffResponse { interface GitAPI { checkIsGitRepository: (directory: string) => Promise; - getGitStatus: (directory: string) => Promise; + getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise; getGitBranches: (directory: string) => Promise; getGitLog: (directory: string, options?: { maxCount?: number }) => Promise; getCurrentGitIdentity: (directory: string) => Promise; @@ -216,7 +217,8 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu } } - if (haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true; + // Skip diffStats comparison when light mode omits them (undefined) + if (newStatus.diffStats !== undefined && haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true; return false; }; @@ -249,21 +251,24 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus | } } - const oldStats = oldStatus?.diffStats ?? {}; - const newStats = newStatus.diffStats ?? {}; - const allStatPaths = new Set([...Object.keys(oldStats), ...Object.keys(newStats)]); + // Only compare diffStats when light mode provides them (non-undefined) + if (newStatus.diffStats !== undefined) { + const oldStats = oldStatus?.diffStats ?? {}; + const newStats = newStatus.diffStats ?? {}; + const allStatPaths = new Set([...Object.keys(oldStats), ...Object.keys(newStats)]); - for (const filePath of allStatPaths) { - const oldEntry = oldStats[filePath]; - const newEntry = newStats[filePath]; + for (const filePath of allStatPaths) { + const oldEntry = oldStats[filePath]; + const newEntry = newStats[filePath]; - if (!oldEntry || !newEntry) { - changed.add(filePath); - continue; - } + if (!oldEntry || !newEntry) { + changed.add(filePath); + continue; + } - if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) { - changed.add(filePath); + if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) { + changed.add(filePath); + } } } @@ -371,7 +376,7 @@ export const useGitStore = create()( return false; } - const newStatus = await git.getGitStatus(directory); + const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined); if (hasStatusChanged(dirState.status, newStatus)) { statusChanged = true; @@ -402,10 +407,15 @@ export const useGitStore = create()( bumpDiffFetchGeneration(directory); } + // Preserve diffStats from previous status when light mode returns none + const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats + ? { ...newStatus, diffStats: currentDirState.status.diffStats } + : newStatus; + newDirectories.set(directory, { ...currentDirState, isGitRepo: true, - status: newStatus, + status: mergedStatus, diffCache: nextDiffCache, lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt, lastStatusFetch: Date.now(), @@ -534,8 +544,7 @@ export const useGitStore = create()( await get().fetchIdentity(directory, git); - // Pre-fetch all diffs so they're ready when user opens Diff tab - void get().fetchAllDiffs(directory, git); + // Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch) }, @@ -581,6 +590,7 @@ export const useGitStore = create()( const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options; const availablePaths = new Set(dirState.status.files.map((file) => file.path)); + const diffStats = dirState.status.diffStats; const inFlight = getInFlightDiffs(directory); const dedupedPaths: string[] = []; @@ -599,6 +609,11 @@ export const useGitStore = create()( if (inFlight.has(filePath)) { continue; } + // Skip large files during prefetch — they'll be fetched on-demand when user clicks + const stats = diffStats?.[filePath]; + if (stats && (stats.insertions + stats.deletions) > DIFF_PREFETCH_LARGE_FILE_THRESHOLD) { + continue; + } dedupedPaths.push(filePath); } @@ -731,18 +746,24 @@ export const useGitStore = create()( let anyStatusChanged = false; + const heavyFollowUps: string[] = []; for (const targetDirectory of pollTargets) { - const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true }); + const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true, mode: 'light' }); if (statusChanged) { anyStatusChanged = true; + heavyFollowUps.push(targetDirectory); if (targetDirectory === activeDirectory) { await get().fetchLog(activeDirectory, git); - // Pre-fetch all diffs so they're ready when user opens Diff tab - void get().fetchAllDiffs(activeDirectory, git); + // Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch) } } } + // Light mode detected real changes — follow up with heavy fetch for diffStats + for (const dir of heavyFollowUps) { + get().fetchStatus(dir, git, { silent: true }); + } + const bounds = getPollingBounds(get().pollingMode); if (anyStatusChanged) { // Reset to base interval on changes diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts new file mode 100644 index 00000000..d78b1bd0 --- /dev/null +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -0,0 +1,308 @@ +import { create } from 'zustand'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import { listGlobalSessionPages } from '@/stores/globalSessions'; + +type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; + +type LoadResult = { + activeSessions: Session[]; + archivedSessions: Session[]; +}; + +type GlobalSessionsState = { + activeSessions: Session[]; + archivedSessions: Session[]; + sessionsByDirectory: Map; + hasLoaded: boolean; + status: GlobalSessionsStatus; + loadSessions: (fallbackActive?: Session[]) => Promise; + applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void; + upsertSession: (session: Session) => void; + removeSessions: (ids: Iterable) => void; + archiveSessions: (ids: Iterable, archivedAt?: number) => void; +}; + +const PAGE_SIZE = 200; + +let inflightLoad: Promise | null = null; + +const normalizePath = (value?: string | null): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const replaced = trimmed.replace(/\\/g, '/'); + if (replaced === '/') { + return '/'; + } + return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; +}; + +export const resolveGlobalSessionDirectory = (session: Session): string | null => { + const record = session as Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; + }; + + return normalizePath(record.directory ?? null) + ?? normalizePath(record.project?.worktree ?? null); +}; + +const buildSessionsByDirectory = (sessions: Session[]): Map => { + const next = new Map(); + for (const session of sessions) { + const directory = resolveGlobalSessionDirectory(session); + if (!directory) { + continue; + } + const existing = next.get(directory); + if (existing) { + existing.push(session); + continue; + } + next.set(directory, [session]); + } + return next; +}; + +const getSessionSignature = (session: Session): string => { + return [ + session.id, + session.title ?? '', + session.time?.created ?? 0, + session.time?.updated ?? 0, + session.time?.archived ?? 0, + session.share ? 1 : 0, + resolveGlobalSessionDirectory(session) ?? '', + ].join(':'); +}; + +const sameSessionList = (prev: Session[], next: Session[]): boolean => { + if (prev === next) { + return true; + } + if (prev.length !== next.length) { + return false; + } + for (let index = 0; index < prev.length; index += 1) { + if (getSessionSignature(prev[index]) !== getSessionSignature(next[index])) { + return false; + } + } + return true; +}; + +const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => { + const index = sessions.findIndex((candidate) => candidate.id === session.id); + if (index === -1) { + return [session, ...sessions]; + } + if (getSessionSignature(sessions[index]) === getSessionSignature(session)) { + return sessions; + } + const next = [...sessions]; + next[index] = session; + return next; +}; + +const applySnapshot = ( + state: GlobalSessionsState, + activeSessions: Session[], + archivedSessions: Session[], + status: GlobalSessionsStatus, +): Partial | GlobalSessionsState => { + const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions) + ? state.activeSessions + : activeSessions; + const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions) + ? state.archivedSessions + : archivedSessions; + const nextSessionsByDirectory = nextActiveSessions === state.activeSessions + ? state.sessionsByDirectory + : buildSessionsByDirectory(nextActiveSessions); + + if ( + nextActiveSessions === state.activeSessions + && nextArchivedSessions === state.archivedSessions + && nextSessionsByDirectory === state.sessionsByDirectory + && state.hasLoaded + && state.status === status + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: nextSessionsByDirectory, + hasLoaded: true, + status, + }; +}; + +export const useGlobalSessionsStore = create((set, get) => ({ + activeSessions: [], + archivedSessions: [], + sessionsByDirectory: new Map(), + hasLoaded: false, + status: 'idle', + + applySnapshot: (activeSessions, archivedSessions, status = 'ready') => { + set((state) => applySnapshot(state, activeSessions, archivedSessions, status)); + }, + + loadSessions: async (fallbackActive) => { + if (inflightLoad) { + return inflightLoad; + } + + set((state) => (state.status === 'loading' ? state : { status: 'loading' })); + + inflightLoad = (async () => { + const current = get(); + + try { + const sdk = opencodeClient.getSdkClient(); + const [activeResult, archivedResult] = await Promise.allSettled([ + listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), + listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), + ]); + + const nextActiveSessions = activeResult.status === 'fulfilled' + ? activeResult.value + : (fallbackActive ?? current.activeSessions); + const nextArchivedSessions = archivedResult.status === 'fulfilled' + ? archivedResult.value + : current.archivedSessions; + + if (activeResult.status === 'rejected') { + console.warn('[GlobalSessions] Failed to load active sessions, using fallback:', activeResult.reason); + } + if (archivedResult.status === 'rejected') { + console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason); + } + + set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready')); + return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; + } catch (error) { + const nextActiveSessions = fallbackActive ?? current.activeSessions; + const nextArchivedSessions = current.archivedSessions; + console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error); + set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error')); + return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; + } finally { + inflightLoad = null; + } + })(); + + return inflightLoad; + }, + + upsertSession: (session) => { + set((state) => { + const isArchived = Boolean(session.time?.archived); + const nextActiveSessions = isArchived + ? state.activeSessions.filter((candidate) => candidate.id !== session.id) + : upsertSessionIntoList(state.activeSessions, session); + const nextArchivedSessions = isArchived + ? upsertSessionIntoList(state.archivedSessions, session) + : state.archivedSessions.filter((candidate) => candidate.id !== session.id); + + if ( + nextActiveSessions === state.activeSessions + && nextArchivedSessions === state.archivedSessions + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: nextActiveSessions === state.activeSessions + ? state.sessionsByDirectory + : buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, + + removeSessions: (ids) => { + const idSet = ids instanceof Set ? ids : new Set(ids); + if (idSet.size === 0) { + return; + } + + set((state) => { + const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id)); + const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); + + if ( + nextActiveSessions.length === state.activeSessions.length + && nextArchivedSessions.length === state.archivedSessions.length + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, + + archiveSessions: (ids, archivedAt = Date.now()) => { + const idSet = ids instanceof Set ? ids : new Set(ids); + if (idSet.size === 0) { + return; + } + + set((state) => { + const movedSessions: Session[] = []; + const nextActiveSessions = state.activeSessions.filter((session) => { + if (!idSet.has(session.id)) { + return true; + } + + movedSessions.push({ + ...session, + time: { + ...session.time, + archived: archivedAt, + }, + }); + return false; + }); + + if (movedSessions.length === 0) { + return state; + } + + const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); + + return { + activeSessions: nextActiveSessions, + archivedSessions: [...movedSessions, ...remainingArchivedSessions], + sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, +})); + +export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise => { + const state = useGlobalSessionsStore.getState(); + if (state.hasLoaded && state.status !== 'error') { + return { + activeSessions: state.activeSessions, + archivedSessions: state.archivedSessions, + }; + } + return state.loadSessions(fallbackActive); +}; + +export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise => { + return useGlobalSessionsStore.getState().loadSessions(fallbackActive); +}; diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 77ecf9bb..10fd12c3 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; import { opencodeClient } from '@/lib/opencode/client'; @@ -7,7 +8,7 @@ import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; -import { useSessionStore } from './sessionStore'; +// sessionStore removed — sync bootstrap handles session loading import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; @@ -186,7 +187,7 @@ export const useMultiRunStore = create()( () => opencodeClient.createSession({ title: sessionTitle }) ); - useSessionStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); + useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); createdRuns.push({ sessionId: session.id, @@ -228,12 +229,7 @@ export const useMultiRunStore = create()( url: f.url, })); - // Refresh sessions list so sidebar shows the new sessions immediately - try { - await useSessionStore.getState().loadSessions(); - } catch { - // Ignore refresh errors - } + // Session list refresh handled by sync bootstrap via SSE events // Setup commands run via SDK worktree startCommand. diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts deleted file mode 100644 index fb4eba56..00000000 --- a/packages/ui/src/stores/useSessionStore.ts +++ /dev/null @@ -1,1484 +0,0 @@ -import { create } from "zustand"; -import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools } from "zustand/middleware"; -import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; -import type { PermissionRequest, PermissionResponse } from "@/types/permission"; -import type { QuestionRequest } from "@/types/question"; -import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes"; - -import { useSessionStore as useSessionManagementStore } from "./sessionStore"; -import { useMessageStore } from "./messageStore"; -import { useFileStore } from "./fileStore"; -import { useContextStore } from "./contextStore"; -import { usePermissionStore } from "./permissionStore"; -import { useQuestionStore } from "./questionStore"; -import { opencodeClient } from "@/lib/opencode/client"; -import { useDirectoryStore } from "./useDirectoryStore"; -import { useConfigStore } from "./useConfigStore"; -import { useProjectsStore } from "./useProjectsStore"; -import { useSessionFoldersStore } from "./useSessionFoldersStore"; -import { getSafeStorage } from "./utils/safeStorage"; -import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"; -import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"; -import { flattenAssistantTextParts } from "@/lib/messages/messageText"; -import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors"; -import type { ProjectEntry } from "@/lib/api/types"; -import type { WorktreeMetadata } from "@/types/worktree"; -import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; -import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"; - -export type { AttachedFile, EditPermissionMode }; -export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes"; - -declare global { - interface Window { - __zustand_session_store__?: UseBoundStore>; - } -} - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const replaced = trimmed.replace(/\\/g, "/"); - if (replaced === "/") { - return "/"; - } - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; -}; - -const sessionChoiceAnalysisSignature = new Map(); -const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"; - -type PersistedDraftTarget = { - projectId: string | null; - directory: string | null; -}; - -const safeStorage = getSafeStorage(); - -const readPersistedDraftTarget = (): PersistedDraftTarget | null => { - try { - const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown }; - return { - projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null, - directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null), - }; - } catch { - return null; - } -}; - -const persistDraftTarget = (target: PersistedDraftTarget): void => { - try { - safeStorage.setItem(DRAFT_TARGET_STORAGE_KEY, JSON.stringify(target)); - } catch { - // ignored - } -}; - -const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => { - const normalizedDirectory = normalizePath(directory); - if (!normalizedDirectory) { - return null; - } - - let bestMatch: ProjectEntry | null = null; - for (const project of projects) { - const projectPath = normalizePath(project.path); - if (!projectPath) { - continue; - } - const isExact = normalizedDirectory === projectPath; - const isNested = normalizedDirectory.startsWith(`${projectPath}/`); - if (!isExact && !isNested) { - continue; - } - if (!bestMatch || projectPath.length > (normalizePath(bestMatch.path)?.length ?? 0)) { - bestMatch = project; - } - } - return bestMatch; -}; - -const resolveProjectFromWorktreeDirectory = ( - projects: ProjectEntry[], - availableWorktreesByProject: Map, - directory: string | null, -): ProjectEntry | null => { - const normalizedDirectory = normalizePath(directory); - if (!normalizedDirectory) { - return null; - } - - let matchedWorktree: WorktreeMetadata | null = null; - let matchedProjectPath: string | null = null; - let bestPathLength = -1; - - for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) { - for (const worktree of worktrees) { - const worktreePath = normalizePath(worktree.path); - if (!worktreePath) { - continue; - } - const isExact = normalizedDirectory === worktreePath; - const isNested = normalizedDirectory.startsWith(`${worktreePath}/`); - if (!isExact && !isNested) { - continue; - } - if (worktreePath.length > bestPathLength) { - bestPathLength = worktreePath.length; - matchedWorktree = worktree; - matchedProjectPath = normalizePath(projectPath); - } - } - } - - if (!matchedWorktree) { - return null; - } - - const normalizedMetadataProjectPath = normalizePath(matchedWorktree.projectDirectory); - const candidates = [normalizedMetadataProjectPath, matchedProjectPath].filter((value): value is string => Boolean(value)); - - for (const candidatePath of candidates) { - const exact = projects.find((project) => normalizePath(project.path) === candidatePath) ?? null; - if (exact) { - return exact; - } - const nested = resolveProjectForDirectory(projects, candidatePath); - if (nested) { - return nested; - } - } - - return null; -}; - -const resolveDraftProjectForDirectory = ( - projects: ProjectEntry[], - availableWorktreesByProject: Map, - directory: string | null, -): ProjectEntry | null => { - return resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) - ?? resolveProjectForDirectory(projects, directory); -}; - -const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => { - const lastMessage = messages[messages.length - 1]; - const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : ''; - const lastAssistant = [...messages] - .reverse() - .find((message) => message.info?.role === 'assistant'); - const lastAssistantId = typeof lastAssistant?.info?.id === 'string' ? lastAssistant.info.id : ''; - return `${messages.length}:${lastMessageId}:${lastAssistantId}`; -}; - -const resolveSessionDirectory = ( - sessions: Session[], - sessionId: string | null | undefined, - getWorktreeMetadata: (id: string) => { path?: string } | undefined, -): string | null => { - if (!sessionId) { - return null; - } - const metadataPath = getWorktreeMetadata(sessionId)?.path; - if (typeof metadataPath === "string" && metadataPath.trim().length > 0) { - return normalizePath(metadataPath); - } - - const target = sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - if (!target) { - return null; - } - return normalizePath(target.directory ?? null); -}; - -export const useSessionStore = create()( - devtools( - (set, get) => ({ - - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - messages: new Map(), - sessionMemoryState: new Map(), - sessionHistoryMeta: new Map(), - messageStreamStates: new Map(), - sessionCompactionUntil: new Map(), - sessionAbortFlags: new Map(), - permissions: new Map(), - questions: new Map(), - attachedFiles: [], - isLoading: false, - error: null, - streamingMessageIds: new Map(), - abortControllers: new Map(), - lastUsedProvider: null, - isSyncing: false, - sessionModelSelections: new Map(), - sessionAgentSelections: new Map(), - sessionAgentModelSelections: new Map(), - webUICreatedSessions: new Set(), - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - currentAgentContext: new Map(), - sessionContextUsage: new Map(), - sessionAgentEditModes: new Map(), - abortPromptSessionId: null, - abortPromptExpiresAt: null, - sessionStatus: new Map(), - sessionAttentionStates: new Map(), - userSummaryTitles: new Map(), - pendingInputText: null, - pendingInputMode: 'replace', - pendingSyntheticParts: null, - newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null }, - - // Voice state (initialized to disconnected/idle) - voiceStatus: 'disconnected', - voiceMode: 'idle', - - // Voice actions - setVoiceStatus: (status: import("./types/sessionTypes").VoiceStatus) => { - set({ voiceStatus: status }); - }, - setVoiceMode: (mode: import("./types/sessionTypes").VoiceMode) => { - set({ voiceMode: mode }); - }, - - getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().getSessionAgentEditMode(sessionId, agentName, defaultMode); - }, - - toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().toggleSessionAgentEditMode(sessionId, agentName, defaultMode); - }, - - setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().setSessionAgentEditMode(sessionId, agentName, mode, defaultMode); - }, - - loadSessions: () => useSessionManagementStore.getState().loadSessions(), - - openNewSessionDraft: (options) => { - const projectsState = useProjectsStore.getState(); - const projects = projectsState.projects; - const availableWorktreesByProject = get().availableWorktreesByProject; - const activeProject = projectsState.getActiveProject(); - const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null); - const persistedTarget = readPersistedDraftTarget(); - - const explicitDirectory = options?.directoryOverride !== undefined - ? normalizePath(options.directoryOverride) - : null; - const explicitProject = options?.projectId - ? projects.find((project) => project.id === options.projectId) ?? null - : null; - - const inferredProjectFromDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory); - const fallbackProject = (() => { - if (activeProject) { - return activeProject; - } - if (projectsState.activeProjectId) { - return projects.find((project) => project.id === projectsState.activeProjectId) ?? null; - } - return projects[0] ?? null; - })(); - - const persistedProjectById = persistedTarget?.projectId - ? projects.find((project) => project.id === persistedTarget.projectId) ?? null - : null; - const persistedProjectByDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null); - const currentDirectoryProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory); - - const selectedProject = (() => { - if (explicitProject || explicitDirectory !== null) { - return explicitProject ?? inferredProjectFromDirectory ?? fallbackProject; - } - if (currentDirectory) { - return currentDirectoryProject ?? fallbackProject; - } - return persistedProjectByDirectory ?? persistedProjectById ?? fallbackProject; - })(); - - const directory = (() => { - if (explicitDirectory !== null) { - return explicitDirectory; - } - if (explicitProject) { - return normalizePath(explicitProject.path ?? null); - } - if (currentDirectory) { - return currentDirectory; - } - if (persistedTarget?.directory) { - return persistedTarget.directory; - } - return normalizePath(selectedProject?.path ?? null); - })(); - - persistDraftTarget({ - projectId: selectedProject?.id ?? null, - directory, - }); - - set({ - newSessionDraft: { - open: true, - selectedProjectId: selectedProject?.id ?? null, - directoryOverride: directory, - pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, - bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride === true, - parentID: options?.parentID ?? null, - title: options?.title, - initialPrompt: options?.initialPrompt, - syntheticParts: options?.syntheticParts, - targetFolderId: options?.targetFolderId, - }, - currentSessionId: null, - error: null, - // Set pending input text if initialPrompt is provided - ...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}), - }); - - try { - const configState = useConfigStore.getState(); - const visibleAgents = configState.getVisibleAgents(); - - // Priority: settingsDefaultAgent → build → first visible - let agentName: string | undefined; - if (configState.settingsDefaultAgent) { - const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); - if (settingsAgent) { - agentName = settingsAgent.name; - } - } - if (!agentName) { - agentName = - visibleAgents.find((agent) => agent.name === 'build')?.name || - visibleAgents[0]?.name; - } - - if (agentName) { - configState.setAgent(agentName); - } - } catch { - // ignored - } - }, - - overrideNewSessionDraftTarget: (options) => { - const projectsState = useProjectsStore.getState(); - const projects = projectsState.projects; - const availableWorktreesByProject = get().availableWorktreesByProject; - const explicitDirectory = normalizePath(options?.directoryOverride ?? null); - const explicitProject = options?.projectId - ? projects.find((project) => project.id === options.projectId) ?? null - : null; - const inferredProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory); - const fallbackProject = explicitProject ?? inferredProject ?? projectsState.getActiveProject() ?? projects[0] ?? null; - const selectedProject = explicitProject ?? inferredProject ?? fallbackProject; - const nextDirectory = explicitDirectory ?? normalizePath(selectedProject?.path ?? null); - - persistDraftTarget({ - projectId: selectedProject?.id ?? null, - directory: nextDirectory, - }); - - set((state) => { - const previousDraft = state.newSessionDraft; - const hasPendingWorktreeRequestId = Object.prototype.hasOwnProperty.call(options, 'pendingWorktreeRequestId'); - const hasBootstrapPendingDirectory = Object.prototype.hasOwnProperty.call(options, 'bootstrapPendingDirectory'); - return { - newSessionDraft: { - ...previousDraft, - open: true, - selectedProjectId: selectedProject?.id ?? null, - directoryOverride: nextDirectory, - pendingWorktreeRequestId: hasPendingWorktreeRequestId - ? (options.pendingWorktreeRequestId ?? null) - : (previousDraft.pendingWorktreeRequestId ?? null), - bootstrapPendingDirectory: hasBootstrapPendingDirectory - ? normalizePath(options.bootstrapPendingDirectory ?? null) - : (previousDraft.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride === true, - title: options?.title ?? previousDraft.title, - initialPrompt: options?.initialPrompt ?? previousDraft.initialPrompt, - }, - currentSessionId: null, - error: null, - ...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}), - }; - }); - }, - - setNewSessionDraftTarget: ({ projectId, directoryOverride }, options) => { - const projects = useProjectsStore.getState().projects; - const project = projectId - ? projects.find((entry) => entry.id === projectId) ?? null - : null; - const normalizedDirectory = normalizePath(directoryOverride); - const normalizedProjectPath = normalizePath(project?.path ?? null); - const nextDirectory = normalizedDirectory ?? normalizedProjectPath ?? null; - - let didUpdate = false; - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - if ( - options?.force !== true - && ( - state.newSessionDraft.pendingWorktreeRequestId - || state.newSessionDraft.bootstrapPendingDirectory - || state.newSessionDraft.preserveDirectoryOverride - ) - ) { - return state; - } - didUpdate = true; - return { - newSessionDraft: { - ...state.newSessionDraft, - selectedProjectId: project?.id ?? null, - directoryOverride: nextDirectory, - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: null, - preserveDirectoryOverride: false, - parentID: null, - }, - }; - }); - - if (didUpdate) { - persistDraftTarget({ - projectId: project?.id ?? null, - directory: nextDirectory, - }); - } - }, - - closeNewSessionDraft: () => { - const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId; - set({ - newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined }, - currentSessionId: realCurrentSessionId, - }); - }, - - setPendingDraftWorktreeRequest: (requestId) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - pendingWorktreeRequestId: requestId, - }, - }; - }); - }, - - resolvePendingDraftWorktreeTarget: (requestId, directory, options) => { - set((state) => { - if (!state.newSessionDraft?.open || state.newSessionDraft.pendingWorktreeRequestId !== requestId) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - selectedProjectId: options?.projectId ?? state.newSessionDraft.selectedProjectId ?? null, - directoryOverride: normalizePath(directory), - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? state.newSessionDraft.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride ?? true, - }, - }; - }); - }, - - setDraftBootstrapPendingDirectory: (directory) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - bootstrapPendingDirectory: normalizePath(directory), - }, - }; - }); - }, - - setDraftPreserveDirectoryOverride: (value) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - preserveDirectoryOverride: value, - }, - }; - }); - }, - - createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { - const draft = get().newSessionDraft; - const targetFolderId = draft.targetFolderId; - get().closeNewSessionDraft(); - - const result = await useSessionManagementStore.getState().createSession(title, directoryOverride, parentID); - - if (result?.id) { - await get().setCurrentSession(result.id); - const finalScopeKey = directoryOverride || get().lastLoadedDirectory || result.directory; - if (targetFolderId && finalScopeKey) { - useSessionFoldersStore.getState().addSessionToFolder(finalScopeKey, targetFolderId, result.id); - } - } - return result; - }, - createSessionFromAssistantMessage: async (sourceMessageId: string) => { - if (!sourceMessageId) { - return; - } - - const messageStore = useMessageStore.getState(); - const { messages, lastUsedProvider } = messageStore; - let sourceEntry: { info: Message; parts: Part[] } | undefined; - let sourceSessionId: string | undefined; - - messages.forEach((messageList, sessionId) => { - const found = messageList.find((entry) => entry.info?.id === sourceMessageId); - if (found && !sourceEntry) { - sourceEntry = found; - sourceSessionId = sessionId; - } - }); - - if (!sourceEntry || sourceEntry.info.role !== "assistant") { - return; - } - - const assistantPlanText = flattenAssistantTextParts(sourceEntry.parts); - if (!assistantPlanText.trim()) { - return; - } - - const sessionManagementStore = useSessionManagementStore.getState(); - const directory = resolveSessionDirectory( - sessionManagementStore.sessions, - sourceSessionId ?? null, - sessionManagementStore.getWorktreeMetadata, - ); - - const session = await get().createSession(undefined, directory ?? null, null); - if (!session) { - return; - } - - const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState(); - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - - if (!providerID || !modelID) { - return; - } - - await opencodeClient.sendMessage({ - id: session.id, - providerID, - modelID, - text: assistantPlanText, - prefaceText: EXECUTION_FORK_META_TEXT, - agent: currentAgentName ?? undefined, - }); - }, - deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options), - deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options), - archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id), - archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options), - updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title), - shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id), - unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id), - setCurrentSession: async (id: string | null) => { - if (id) { - get().closeNewSessionDraft(); - } - - const previousSessionId = useSessionManagementStore.getState().currentSessionId; - - const sessionDirectory = resolveSessionDirectory( - useSessionManagementStore.getState().sessions, - id, - useSessionManagementStore.getState().getWorktreeMetadata - ); - const fallbackDirectory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory ?? null; - const resolvedDirectory = sessionDirectory ?? fallbackDirectory; - - try { - opencodeClient.setDirectory(resolvedDirectory ?? undefined); - } catch (error) { - console.warn("Failed to set OpenCode directory for session switch:", error); - } - - if (previousSessionId && previousSessionId !== id) { - const memoryState = get().sessionMemoryState.get(previousSessionId); - if (!memoryState?.isStreaming) { - - const previousMessages = get().messages.get(previousSessionId) || []; - if (previousMessages.length > 0) { - get().updateViewportAnchor(previousSessionId, previousMessages.length - 1); - } - } - } - - useSessionManagementStore.getState().setCurrentSession(id); - - if (id) { - - const existingMessages = get().messages.get(id); - const historyMeta = get().sessionHistoryMeta.get(id); - const needsHistoryBootstrap = - !historyMeta || - typeof historyMeta.complete !== 'boolean'; - - if (!existingMessages || needsHistoryBootstrap) { - - await get().loadMessages(id); - } - - // Analyze session messages to extract agent/model/variant choices - // This ensures context is available even when ModelControls isn't mounted - const sessionMessages = get().messages.get(id); - if (sessionMessages && sessionMessages.length > 0) { - const agents = useConfigStore.getState().agents; - if (agents.length > 0) { - const analysisSignature = buildSessionChoiceAnalysisSignature(sessionMessages); - if (sessionChoiceAnalysisSignature.get(id) === analysisSignature) { - return; - } - try { - await useContextStore.getState().analyzeAndSaveExternalSessionChoices( - id, - agents, - get().messages - ); - sessionChoiceAnalysisSignature.set(id, analysisSignature); - } catch (error) { - console.warn('Failed to analyze session choices:', error); - } - } - } - } - - }, - loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit), - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => { - const draft = get().newSessionDraft; - const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; - - const setStatus = (sessionId: string, type: 'idle' | 'busy') => { - set((state) => { - const next = new Map(state.sessionStatus ?? new Map()); - next.set(sessionId, { type }); - return { sessionStatus: next }; - }); - }; - - if (draft?.open) { - const draftTargetFolderId = draft.targetFolderId; - let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null; - const draftProjectId = draft.selectedProjectId ?? null; - - if (draft.pendingWorktreeRequestId) { - draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId); - get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride); - } - - const created = await useSessionManagementStore - .getState() - .createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null); - - if (!created?.id) { - throw new Error('Failed to create session'); - } - - persistDraftTarget({ - projectId: draftProjectId, - directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), - }); - - const configState = useConfigStore.getState(); - const draftAgentName = configState.currentAgentName; - const effectiveDraftAgent = trimmedAgent ?? draftAgentName; - const draftProviderId = configState.currentProviderId; - const draftModelId = configState.currentModelId; - - if (draftProviderId && draftModelId) { - try { - useContextStore.getState().saveSessionModelSelection(created.id, draftProviderId, draftModelId); - } catch { - // ignored - } - } - - if (effectiveDraftAgent) { - try { - useContextStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent); - } catch { - // ignored - } - - if (draftProviderId && draftModelId) { - try { - useContextStore - .getState() - .saveAgentModelForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId); - } catch { - // ignored - } - - try { - useContextStore - .getState() - .saveAgentModelVariantForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId, variant); - } catch { - // ignored - } - } - } - - try { - useSessionManagementStore - .getState() - .initializeNewOpenChamberSession(created.id, configState.agents); - } catch { - // ignored - } - - // Capture synthetic parts before clearing draft - const draftSyntheticParts = draft.syntheticParts; - - get().closeNewSessionDraft(); - await get().setCurrentSession(created.id); - - // Assign to target folder if session was created from folder's + button - if (draftTargetFolderId) { - const scopeKey = draftDirectoryOverride || created.directory || null; - if (scopeKey) { - useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id); - } - } - - setStatus(created.id, 'busy'); - - // Merge draft synthetic parts with any additional parts passed to sendMessage - const mergedAdditionalParts = draftSyntheticParts?.length - ? [...(additionalParts || []), ...draftSyntheticParts] - : additionalParts; - - const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null); - if (createdDirectory) { - await waitForWorktreeBootstrap(createdDirectory); - } - - try { - markPendingUserSendAnimation(created.id); - return await useMessageStore - .getState() - .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode); - } catch (error) { - setStatus(created.id, 'idle'); - throw error; - } - } - - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - const sessionAgentSelection = currentSessionId - ? useContextStore.getState().getSessionAgentSelection(currentSessionId) - : null; - const configAgentName = useConfigStore.getState().currentAgentName; - const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined; - - if (currentSessionId && effectiveAgent) { - try { - useContextStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent); - } catch { - // ignored - } - - try { - useContextStore - .getState() - .saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant); - } catch { - // ignored - } - } - - if (currentSessionId) { - setStatus(currentSessionId, 'busy'); - - const memoryState = get().sessionMemoryState.get(currentSessionId); - if (!memoryState || !memoryState.lastUserMessageAt) { - const currentMemoryState = get().sessionMemoryState; - const newMemoryState = new Map(currentMemoryState); - newMemoryState.set(currentSessionId, { - viewportAnchor: memoryState?.viewportAnchor ?? 0, - isStreaming: memoryState?.isStreaming ?? false, - lastAccessedAt: Date.now(), - backgroundMessageCount: memoryState?.backgroundMessageCount ?? 0, - lastUserMessageAt: Date.now(), - }); - set({ sessionMemoryState: newMemoryState }); - } - } - - const currentSessionDirectory = currentSessionId - ? normalizePath(useSessionManagementStore.getState().getDirectoryForSession(currentSessionId)) - : null; - if (currentSessionDirectory) { - await waitForWorktreeBootstrap(currentSessionDirectory); - } - - // Notify server that user sent a message in this session - if (currentSessionId) { - fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - - try { - if (currentSessionId) { - markPendingUserSendAnimation(currentSessionId); - } - return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode); - } catch (error) { - if (currentSessionId) { - setStatus(currentSessionId, 'idle'); - } - throw error; - } - }, - abortCurrentOperation: (sessionIdOverride?: string) => { - const sessionId = sessionIdOverride || useSessionManagementStore.getState().currentSessionId; - return useMessageStore.getState().abortCurrentOperation(sessionId || undefined); - }, - armAbortPrompt: (durationMs = 3000) => { - const sessionId = useSessionManagementStore.getState().currentSessionId; - if (!sessionId) { - return null; - } - const expiresAt = Date.now() + durationMs; - set({ abortPromptSessionId: sessionId, abortPromptExpiresAt: expiresAt }); - return expiresAt; - }, - clearAbortPrompt: () => { - set({ abortPromptSessionId: null, abortPromptExpiresAt: null }); - }, - acknowledgeSessionAbort: (sessionId: string) => { - if (!sessionId) { - return; - } - useMessageStore.getState().acknowledgeSessionAbort(sessionId); - }, - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => { - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - - const effectiveCurrent = currentSessionId || sessionId; - return useMessageStore.getState().addStreamingPart(sessionId, messageId, part, role, effectiveCurrent); - }, - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string) => { - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - const effectiveCurrent = currentSessionId || sessionId; - return useMessageStore.getState().applyPartDelta(sessionId, messageId, partId, field, delta, role, effectiveCurrent); - }, - completeStreamingMessage: (sessionId: string, messageId: string) => useMessageStore.getState().completeStreamingMessage(sessionId, messageId), - markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId), - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo), - updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null), - addPermission: (permission: PermissionRequest) => { - return usePermissionStore.getState().addPermission(permission); - }, - respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response), - dismissPermission: (sessionId: string, requestId: string) => usePermissionStore.getState().dismissPermission(sessionId, requestId), - - addQuestion: (question: QuestionRequest) => useQuestionStore.getState().addQuestion(question), - dismissQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().dismissQuestion(sessionId, requestId), - respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => useQuestionStore.getState().respondToQuestion(sessionId, requestId, answers), - rejectQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().rejectQuestion(sessionId, requestId), - - clearError: () => useSessionManagementStore.getState().clearError(), - getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory), - getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId), - getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId), - getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId), - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => useMessageStore.getState().syncMessages(sessionId, messages, options), - applySessionMetadata: (sessionId: string, metadata: Partial) => useSessionManagementStore.getState().applySessionMetadata(sessionId, metadata), - - addAttachedFile: (file: File) => useFileStore.getState().addAttachedFile(file), - addServerFile: (path: string, name: string, content?: string) => useFileStore.getState().addServerFile(path, name, content), - removeAttachedFile: (id: string) => useFileStore.getState().removeAttachedFile(id), - clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), - - updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), - loadMoreMessages: (sessionId: string, direction: "up" | "down") => useMessageStore.getState().loadMoreMessages(sessionId, direction), - - saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => useContextStore.getState().saveSessionModelSelection(sessionId, providerId, modelId), - getSessionModelSelection: (sessionId: string) => useContextStore.getState().getSessionModelSelection(sessionId), - saveSessionAgentSelection: (sessionId: string, agentName: string) => useContextStore.getState().saveSessionAgentSelection(sessionId, agentName), - getSessionAgentSelection: (sessionId: string) => useContextStore.getState().getSessionAgentSelection(sessionId), - saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId), - getAgentModelForSession: (sessionId: string, agentName: string) => useContextStore.getState().getAgentModelForSession(sessionId, agentName), - saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, variant), - getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().getAgentModelVariantForSession(sessionId, agentName, providerId, modelId), - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Record[]) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().analyzeAndSaveExternalSessionChoices(sessionId, agents, messages); - }, - isOpenChamberCreatedSession: (sessionId: string) => useSessionManagementStore.getState().isOpenChamberCreatedSession(sessionId), - markSessionAsOpenChamberCreated: (sessionId: string) => useSessionManagementStore.getState().markSessionAsOpenChamberCreated(sessionId), - initializeNewOpenChamberSession: (sessionId: string, agents: Record[]) => useSessionManagementStore.getState().initializeNewOpenChamberSession(sessionId, agents), - setWorktreeMetadata: (sessionId: string, metadata) => useSessionManagementStore.getState().setWorktreeMetadata(sessionId, metadata), - setSessionDirectory: (sessionId: string, directory: string | null) => useSessionManagementStore.getState().setSessionDirectory(sessionId, directory), - getWorktreeMetadata: (sessionId: string) => useSessionManagementStore.getState().getWorktreeMetadata(sessionId), - getContextUsage: (contextLimit: number, outputLimit: number) => { - if (get().newSessionDraft?.open) { - return null; - } - - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - if (!currentSessionId) return null; - const messages = useMessageStore.getState().messages; - return useContextStore.getState().getContextUsage(currentSessionId, contextLimit, outputLimit, messages); - }, - updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().updateSessionContextUsage(sessionId, contextLimit, outputLimit, messages); - }, - initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().initializeSessionContextUsage(sessionId, contextLimit, outputLimit, messages); - }, - debugSessionMessages: async (sessionId: string) => { - const messages = normalizeMessageRecordsForProjection( - useMessageStore.getState().messages.get(sessionId) || [] - ); - const session = useSessionManagementStore.getState().sessions.find(s => s.id === sessionId); - console.log(`Debug session ${sessionId}:`, { - session, - messageCount: messages.length, - messages: messages.map(m => ({ - id: m.info.id, - role: m.info.role, - parts: m.parts.length, - tokens: (m.info as Record).tokens - })) - }); - }, - pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts); - }, - updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session), - removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId), - - revertToMessage: async (sessionId: string, messageId: string) => { - // Get the message text before reverting - const messages = useMessageStore.getState().messages.get(sessionId) || []; - const targetMessage = messages.find((m) => m.info.id === messageId); - let messageText = ''; - - if (targetMessage && targetMessage.info.role === 'user') { - // Extract text from user message parts - const textParts = targetMessage.parts.filter((p) => p.type === 'text'); - messageText = textParts - .map((p) => { - const part = p as { text?: string; content?: string }; - return part.text || part.content || ''; - }) - .join('\n') - .trim(); - } - - // Call revert API - const updatedSession = await opencodeClient.revertSession(sessionId, messageId); - - // Update session in store (this stores the revert.messageID) - useSessionManagementStore.getState().updateSession(updatedSession); - - // Filter out reverted messages from the store - // Messages with ID >= revert.messageID should be removed - const currentMessages = useMessageStore.getState().messages.get(sessionId) || []; - const revertMessageId = updatedSession.revert?.messageID; - - if (revertMessageId) { - // Keep only messages before the revert point. - // Fallback to the originally clicked message if SDK returns an id - // that is not loaded in the current in-memory window. - let revertIndex = currentMessages.findIndex((m) => m.info.id === revertMessageId); - if (revertIndex === -1) { - revertIndex = currentMessages.findIndex((m) => m.info.id === messageId); - } - - if (revertIndex !== -1) { - const filteredMessages = currentMessages.slice(0, revertIndex); - useMessageStore.getState().syncMessages(sessionId, filteredMessages, { replace: true }); - } - } - - // Set pending input text for ChatInput to consume - if (messageText) { - set({ pendingInputText: messageText, pendingInputMode: 'replace' }); - } - }, - - handleSlashUndo: async (sessionId: string) => { - const messages = get().messages.get(sessionId) || []; - const userMessages = messages.filter(m => m.info.role === 'user'); - const sessions = get().sessions; - const currentSession = sessions.find(s => s.id === sessionId); - - // No-op when there is nothing to undo/redo - if (userMessages.length === 0) { - return; - } - - // Get current revert state to determine which message to undo next - const revertToId = currentSession?.revert?.messageID; - - // Find the user message AFTER the revert point (or last message if no revert) - let targetMessage; - if (revertToId) { - const revertIndex = userMessages.findIndex(m => m.info.id === revertToId); - targetMessage = userMessages[revertIndex + 1]; - } else { - targetMessage = userMessages[userMessages.length - 1]; - } - - // No-op when there is nothing to undo/redo - if (!targetMessage) { - return; - } - - // Helper to extract text preview - const textPart = targetMessage.parts.find(p => p.type === 'text'); - const preview = typeof textPart === 'object' && textPart && 'text' in textPart - ? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '') - : '[No text]'; - - await get().revertToMessage(sessionId, targetMessage.info.id); - - const { toast } = await import('sonner'); - toast.success(`Undid to: ${preview}`); - }, - - handleSlashRedo: async (sessionId: string) => { - const sessions = get().sessions; - const currentSession = sessions.find(s => s.id === sessionId); - const revertToId = currentSession?.revert?.messageID; - - // No-op when there is nothing to undo/redo - if (!revertToId) { - return; - } - - const messages = get().messages.get(sessionId) || []; - const userMessages = messages.filter(m => m.info.role === 'user'); - - // Find the user message BEFORE the revert point - const revertIndex = userMessages.findIndex(m => m.info.id === revertToId); - const targetMessage = userMessages[revertIndex - 1]; - - if (targetMessage) { - // Partial redo: move to previous message - const textPart = targetMessage.parts.find(p => p.type === 'text'); - const preview = typeof textPart === 'object' && textPart && 'text' in textPart - ? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '') - : '[No text]'; - - await get().revertToMessage(sessionId, targetMessage.info.id); - - const { toast } = await import('sonner'); - toast.success(`Redid to: ${preview}`); - } else { - // Full unrevert: restore all - const session = await opencodeClient.unrevertSession(sessionId); - await useSessionManagementStore.getState().updateSession(session); - await get().loadMessages(sessionId); - - const { toast } = await import('sonner'); - toast.success('Restored all messages'); - } - }, - - forkFromMessage: async (sessionId: string, messageId: string) => { - const sessions = get().sessions; - const existingSession = sessions.find(s => s.id === sessionId); - if (!existingSession) return; - - try { - // 1. Call SDK fork - backend copies all messages up to messageId - const result = await opencodeClient.forkSession(sessionId, messageId); - - if (!result || !result.id) { - const { toast } = await import('sonner'); - toast.error('Failed to fork session'); - return; - } - - // 2. Extract fork point content for input field (text + file attachments) - const messages = get().messages.get(sessionId) || []; - const message = messages.find(m => m.info.id === messageId); - - if (!message) { - const { toast } = await import('sonner'); - toast.error('Message not found'); - return; - } - - // Extract text content from non-synthetic, non-ignored text parts - let inputText = ''; - for (const part of message.parts) { - if (part.type === 'text' && !part.synthetic && !part.ignored) { - const typedPart = part as { text?: string }; - inputText += typedPart.text || ''; - } - } - - // 3. Switch to new session - get().setCurrentSession(result.id); - - // 4. Show fork point as pending input (will populate ChatInput) - if (inputText) { - set({ pendingInputText: inputText, pendingInputMode: 'replace' }); - } - - // Load the new session's messages - await get().loadMessages(result.id); - - const { toast } = await import('sonner'); - toast.success(`Forked from ${existingSession.title}`); - } catch (error) { - console.error('Failed to fork session:', error); - const { toast } = await import('sonner'); - toast.error('Failed to fork session'); - } - }, - - setPendingInputText: (text: string | null, mode: 'replace' | 'append' | 'append-inline' = 'replace') => { - set({ pendingInputText: text, pendingInputMode: mode }); - }, - - consumePendingInputText: () => { - const text = get().pendingInputText; - const mode = get().pendingInputMode; - if (text !== null) { - set({ pendingInputText: null, pendingInputMode: 'replace' }); - } - if (text === null) { - return null; - } - return { text, mode }; - }, - - setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => { - set({ pendingSyntheticParts: parts }); - }, - - consumePendingSyntheticParts: () => { - const parts = get().pendingSyntheticParts; - if (parts !== null) { - set({ pendingSyntheticParts: null }); - } - return parts; - }, - }), - { - name: "composed-session-store", - } - ), -); - -// rAF debounce IDs for useMessageStore -> useSessionStore sync -let messageStoreSyncRafId: number | null = null; -let userSummaryTitlesRafId: ReturnType | null = null; - -useSessionManagementStore.subscribe((state, prevState) => { - - if ( - state.sessions === prevState.sessions && - state.archivedSessions === prevState.archivedSessions && - state.sessionsByDirectory === prevState.sessionsByDirectory && - state.currentSessionId === prevState.currentSessionId && - state.lastLoadedDirectory === prevState.lastLoadedDirectory && - state.isLoading === prevState.isLoading && - state.error === prevState.error && - state.webUICreatedSessions === prevState.webUICreatedSessions && - state.worktreeMetadata === prevState.worktreeMetadata && - state.availableWorktrees === prevState.availableWorktrees && - state.availableWorktreesByProject === prevState.availableWorktreesByProject - ) { - return; - } - - const draftOpen = useSessionStore.getState().newSessionDraft?.open; - - useSessionStore.setState({ - sessions: state.sessions, - archivedSessions: state.archivedSessions, - sessionsByDirectory: state.sessionsByDirectory, - currentSessionId: draftOpen ? null : state.currentSessionId, - lastLoadedDirectory: state.lastLoadedDirectory, - isLoading: state.isLoading, - error: state.error, - webUICreatedSessions: state.webUICreatedSessions, - worktreeMetadata: state.worktreeMetadata, - availableWorktrees: state.availableWorktrees, - availableWorktreesByProject: state.availableWorktreesByProject, - }); -}); - -useMessageStore.subscribe((state, prevState) => { - // Early-return equality check stays outside the rAF so we skip scheduling - // entirely when nothing relevant changed. - if ( - state.messages === prevState.messages && - state.sessionMemoryState === prevState.sessionMemoryState && - state.sessionHistoryMeta === prevState.sessionHistoryMeta && - state.messageStreamStates === prevState.messageStreamStates && - state.sessionCompactionUntil === prevState.sessionCompactionUntil && - state.sessionAbortFlags === prevState.sessionAbortFlags && - state.streamingMessageIds === prevState.streamingMessageIds && - state.abortControllers === prevState.abortControllers && - state.lastUsedProvider === prevState.lastUsedProvider && - state.isSyncing === prevState.isSyncing - ) { - return; - } - - // Debounce the expensive sessionStore update to at most once per animation - // frame. Multiple messageStore updates within the same frame (e.g. several - // SSE tokens arriving before the next paint) collapse into a single setState. - if (messageStoreSyncRafId !== null) { - cancelAnimationFrame(messageStoreSyncRafId); - } - messageStoreSyncRafId = requestAnimationFrame(() => { - messageStoreSyncRafId = null; - - // Read the LATEST state at flush time, not the stale state captured by - // the subscription closure. - const latest = useMessageStore.getState(); - - useSessionStore.setState({ - messages: latest.messages, - sessionMemoryState: latest.sessionMemoryState, - sessionHistoryMeta: latest.sessionHistoryMeta, - messageStreamStates: latest.messageStreamStates, - sessionCompactionUntil: latest.sessionCompactionUntil, - sessionAbortFlags: latest.sessionAbortFlags, - streamingMessageIds: latest.streamingMessageIds, - abortControllers: latest.abortControllers, - lastUsedProvider: latest.lastUsedProvider, - isSyncing: latest.isSyncing, - }); - - // Sidebar titles don't need real-time updates; debounce separately at - // 500 ms so the expensive per-message iteration doesn't happen every - // frame during streaming. - if (userSummaryTitlesRafId !== null) { - clearTimeout(userSummaryTitlesRafId); - } - userSummaryTitlesRafId = setTimeout(() => { - userSummaryTitlesRafId = null; - const titleState = useMessageStore.getState(); - const userSummaryTitles = new Map(); - titleState.messages.forEach((messageList, sessionId) => { - if (!Array.isArray(messageList) || messageList.length === 0) { - return; - } - for (let index = messageList.length - 1; index >= 0; index -= 1) { - const entry = messageList[index]; - if (!entry || !entry.info) { - continue; - } - const info = entry.info as Message & { - summary?: { title?: string | null } | null; - time?: { created?: number | null }; - }; - if (info.role === "user") { - const title = info.summary?.title; - if (typeof title === "string") { - const trimmed = title.trim(); - if (trimmed.length > 0) { - const createdAt = - info.time && typeof info.time.created === "number" - ? info.time.created - : null; - userSummaryTitles.set(sessionId, { title: trimmed, createdAt }); - break; - } - } - } - } - }); - useSessionStore.setState({ userSummaryTitles }); - }, 500); - }); -}); - -useFileStore.subscribe((state, prevState) => { - if (state.attachedFiles === prevState.attachedFiles) { - return; - } - - useSessionStore.setState({ - attachedFiles: state.attachedFiles, - }); -}); - -useContextStore.subscribe((state, prevState) => { - if ( - state.sessionModelSelections === prevState.sessionModelSelections && - state.sessionAgentSelections === prevState.sessionAgentSelections && - state.sessionAgentModelSelections === prevState.sessionAgentModelSelections && - state.currentAgentContext === prevState.currentAgentContext && - state.sessionContextUsage === prevState.sessionContextUsage && - state.sessionAgentEditModes === prevState.sessionAgentEditModes - ) { - return; - } - - useSessionStore.setState({ - sessionModelSelections: state.sessionModelSelections, - sessionAgentSelections: state.sessionAgentSelections, - sessionAgentModelSelections: state.sessionAgentModelSelections, - currentAgentContext: state.currentAgentContext, - sessionContextUsage: state.sessionContextUsage, - sessionAgentEditModes: state.sessionAgentEditModes, - }); -}); - -usePermissionStore.subscribe((state, prevState) => { - if (state.permissions === prevState.permissions) { - return; - } - - useSessionStore.setState({ - permissions: state.permissions, - }); -}); - -useQuestionStore.subscribe((state, prevState) => { - if (state.questions === prevState.questions) { - return; - } - - useSessionStore.setState({ - questions: state.questions, - }); -}); - -useDirectoryStore.subscribe((state, prevState) => { - const nextDirectory = normalizePath(state.currentDirectory ?? null); - const prevDirectory = normalizePath(prevState.currentDirectory ?? null); - if (nextDirectory === prevDirectory) { - return; - } - - const draft = useSessionStore.getState().newSessionDraft; - if (!draft?.open) { - return; - } - - if (draft.pendingWorktreeRequestId || draft.bootstrapPendingDirectory || draft.preserveDirectoryOverride) { - return; - } - - const draftDirectory = normalizePath(draft.directoryOverride); - if (draftDirectory && draftDirectory !== prevDirectory) { - return; - } - - const projects = useProjectsStore.getState().projects; - const resolvedProject = resolveDraftProjectForDirectory( - projects, - useSessionStore.getState().availableWorktreesByProject, - nextDirectory, - ); - - useSessionStore.setState((store) => ({ - newSessionDraft: { - ...store.newSessionDraft, - selectedProjectId: resolvedProject?.id ?? store.newSessionDraft.selectedProjectId ?? null, - directoryOverride: nextDirectory, - parentID: null, - }, - })); - - persistDraftTarget({ - projectId: resolvedProject?.id ?? draft.selectedProjectId ?? null, - directory: nextDirectory, - }); -}); - -const bootDraftOpen = useSessionStore.getState().newSessionDraft?.open; - -useSessionStore.setState({ - sessions: useSessionManagementStore.getState().sessions, - currentSessionId: bootDraftOpen ? null : useSessionManagementStore.getState().currentSessionId, - lastLoadedDirectory: useSessionManagementStore.getState().lastLoadedDirectory, - isLoading: useSessionManagementStore.getState().isLoading, - error: useSessionManagementStore.getState().error, - webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions, - worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata, - availableWorktrees: useSessionManagementStore.getState().availableWorktrees, - availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject, - messages: useMessageStore.getState().messages, - sessionMemoryState: useMessageStore.getState().sessionMemoryState, - sessionHistoryMeta: useMessageStore.getState().sessionHistoryMeta, - messageStreamStates: useMessageStore.getState().messageStreamStates, - sessionCompactionUntil: useMessageStore.getState().sessionCompactionUntil, - sessionAbortFlags: useMessageStore.getState().sessionAbortFlags, - streamingMessageIds: useMessageStore.getState().streamingMessageIds, - abortControllers: useMessageStore.getState().abortControllers, - lastUsedProvider: useMessageStore.getState().lastUsedProvider, - isSyncing: useMessageStore.getState().isSyncing, - permissions: usePermissionStore.getState().permissions, - questions: useQuestionStore.getState().questions, - attachedFiles: useFileStore.getState().attachedFiles, - sessionModelSelections: useContextStore.getState().sessionModelSelections, - sessionAgentSelections: useContextStore.getState().sessionAgentSelections, - sessionAgentModelSelections: useContextStore.getState().sessionAgentModelSelections, - currentAgentContext: useContextStore.getState().currentAgentContext, - sessionContextUsage: useContextStore.getState().sessionContextUsage, - sessionAgentEditModes: useContextStore.getState().sessionAgentEditModes, - abortPromptSessionId: null, - abortPromptExpiresAt: null, -}); - -if (typeof window !== "undefined") { - window.__zustand_session_store__ = useSessionStore; -} diff --git a/packages/ui/src/stores/useTodoStore.ts b/packages/ui/src/stores/useTodoStore.ts deleted file mode 100644 index 4e1818a8..00000000 --- a/packages/ui/src/stores/useTodoStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - -import { opencodeClient } from "@/lib/opencode/client"; -import { useSessionStore } from "./useSessionStore"; - -export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; -export type TodoPriority = "high" | "medium" | "low"; - -export interface TodoItem { - id: string; - content: string; - status: TodoStatus; - priority: TodoPriority; -} - -interface TodoStore { - // Map of sessionId -> todos - sessionTodos: Map; - isLoading: boolean; - - // Actions - loadTodos: (sessionId: string) => Promise; - updateTodos: (sessionId: string, todos: TodoItem[]) => void; - getTodosForSession: (sessionId: string) => TodoItem[]; - clearTodos: (sessionId: string) => void; -} - -type RawTodo = { id: string; content: string; status: string; priority: string }; - -const normalizeTodo = (todo: RawTodo): TodoItem => ({ - id: todo.id, - content: todo.content, - status: (todo.status as TodoStatus) || "pending", - priority: (todo.priority as TodoPriority) || "medium", -}); - -export const useTodoStore = create()( - devtools( - (set, get) => ({ - sessionTodos: new Map(), - isLoading: false, - - loadTodos: async (sessionId: string) => { - if (!sessionId) return; - - set({ isLoading: true }); - - try { - const directory = useSessionStore.getState().getDirectoryForSession(sessionId); - const rawTodos = directory - ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId)) - : await opencodeClient.getSessionTodos(sessionId); - const todos = rawTodos.map(normalizeTodo); - - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.set(sessionId, todos); - return { sessionTodos: newMap, isLoading: false }; - }); - } catch (error) { - console.warn("[TodoStore] Failed to load todos:", error); - set({ isLoading: false }); - } - }, - - updateTodos: (sessionId: string, todos: TodoItem[]) => { - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.set(sessionId, todos); - return { sessionTodos: newMap }; - }); - }, - - getTodosForSession: (sessionId: string) => { - return get().sessionTodos.get(sessionId) || []; - }, - - clearTodos: (sessionId: string) => { - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.delete(sessionId); - return { sessionTodos: newMap }; - }); - }, - }), - { name: "todo-store" } - ) -); - -// Helper to handle SSE todo.updated events -export const handleTodoUpdatedEvent = ( - sessionId: string, - todos: RawTodo[] -): void => { - const normalizedTodos = todos.map(normalizeTodo); - useTodoStore.getState().updateTodos(sessionId, normalizedTodos); -}; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 934ed070..89b26e02 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -12,6 +12,7 @@ export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; export type ActivityRenderMode = 'collapsed' | 'summary'; +export type SessionRetentionAction = 'archive' | 'delete'; type ContextPanelTab = { id: string; @@ -503,6 +504,7 @@ interface UIStore { showDeletionDialog: boolean; autoDeleteEnabled: boolean; autoDeleteAfterDays: number; + sessionRetentionAction: SessionRetentionAction; autoDeleteLastRunAt: number | null; messageLimit: number; fontSize: number; @@ -617,6 +619,7 @@ interface UIStore { setShowDeletionDialog: (value: boolean) => void; setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteAfterDays: (days: number) => void; + setSessionRetentionAction: (value: SessionRetentionAction) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void; setMessageLimit: (value: number) => void; setFontSize: (size: number) => void; @@ -730,6 +733,7 @@ export const useUIStore = create()( showDeletionDialog: true, autoDeleteEnabled: false, autoDeleteAfterDays: 30, + sessionRetentionAction: 'archive', autoDeleteLastRunAt: null, messageLimit: 200, fontSize: 100, @@ -1313,6 +1317,10 @@ export const useUIStore = create()( set({ autoDeleteAfterDays: clampedDays }); }, + setSessionRetentionAction: (value) => { + set({ sessionRetentionAction: value }); + }, + setAutoDeleteLastRunAt: (timestamp) => { set({ autoDeleteLastRunAt: timestamp }); }, @@ -1835,6 +1843,7 @@ export const useUIStore = create()( showDeletionDialog: state.showDeletionDialog, autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, + sessionRetentionAction: state.sessionRetentionAction, autoDeleteLastRunAt: state.autoDeleteLastRunAt, messageLimit: state.messageLimit, fontSize: state.fontSize, diff --git a/packages/ui/src/stores/utils/messageUtils.ts b/packages/ui/src/stores/utils/messageUtils.ts index b4c7aad2..50274297 100644 --- a/packages/ui/src/stores/utils/messageUtils.ts +++ b/packages/ui/src/stores/utils/messageUtils.ts @@ -214,3 +214,73 @@ export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => return normalized as Part; }; + +const deepEqualRecord = (left: Record, right: Record): boolean => { + const keys = new Set([ + ...Object.keys(left), + ...Object.keys(right), + ]); + + for (const key of keys) { + const leftValue = left[key]; + const rightValue = right[key]; + + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + if (!Array.isArray(leftValue) || !Array.isArray(rightValue) || leftValue.length !== rightValue.length) { + return false; + } + for (let index = 0; index < leftValue.length; index += 1) { + if (!deepEqualUnknown(leftValue[index], rightValue[index])) { + return false; + } + } + continue; + } + + if (!deepEqualUnknown(leftValue, rightValue)) { + return false; + } + } + + return true; +}; + +const deepEqualUnknown = (left: unknown, right: unknown): boolean => { + if (left === right) { + return true; + } + + if (!left || !right) { + return false; + } + + if (typeof left !== typeof right) { + return false; + } + + if (typeof left === 'object' && typeof right === 'object') { + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (!deepEqualUnknown(left[index], right[index])) { + return false; + } + } + return true; + } + + return deepEqualRecord(left as Record, right as Record); + } + + return false; +}; + +export const arePartsEquivalent = (left: Part | undefined, right: Part | undefined): boolean => { + if (!left || !right) { + return left === right; + } + + return deepEqualUnknown(left, right); +}; diff --git a/packages/ui/src/stores/utils/streamDebug.ts b/packages/ui/src/stores/utils/streamDebug.ts index f478f673..5ee628d9 100644 --- a/packages/ui/src/stores/utils/streamDebug.ts +++ b/packages/ui/src/stores/utils/streamDebug.ts @@ -15,3 +15,241 @@ export const sessionStatusDebugEnabled = (): boolean => { return false; } }; + +const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf'; + +type PerfCounter = { + count: number; + total: number; + max: number; + last: number; +}; + +type StreamPerfState = { + counters: Map; + startedAt: number; + lastUpdatedAt: number; +}; + +export type StreamPerfEntry = { + metric: string; + count: number; + avg: number; + max: number; + total: number; + last: number; +}; + +export type StreamPerfSnapshot = { + enabled: boolean; + startedAt: number | null; + lastUpdatedAt: number | null; + durationMs: number; + entries: StreamPerfEntry[]; +}; + +declare global { + interface Window { + __openchamberStreamPerfState?: StreamPerfState; + __openchamberVsCodeStreamPerfState?: { + counters: Map; + lastReportAt?: number; + lastUpdatedAt?: number; + reportTimer?: number | null; + startedAt?: number; + }; + } +} + +export const streamPerfEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const nowMs = (): number => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +}; + +const ensureStreamPerfState = (): StreamPerfState | null => { + if (!streamPerfEnabled() || typeof window === 'undefined') { + return null; + } + + if (!window.__openchamberStreamPerfState) { + const startedAt = Date.now(); + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt, + lastUpdatedAt: startedAt, + }; + } + + return window.__openchamberStreamPerfState; +}; + +const normalizePerfEntries = (counters: Map): StreamPerfEntry[] => { + return Array.from(counters.entries()) + .map(([metric, bucket]) => ({ + metric, + count: bucket.count, + avg: bucket.count > 0 ? Number((bucket.total / bucket.count).toFixed(3)) : 0, + max: Number(bucket.max.toFixed(3)), + total: Number(bucket.total.toFixed(3)), + last: Number(bucket.last.toFixed(3)), + })) + .sort((a, b) => b.total - a.total || b.count - a.count); +}; + +const updatePerfCounter = (metric: string, amount: number): void => { + const state = ensureStreamPerfState(); + if (!state) { + return; + } + + const bucket = state.counters.get(metric) ?? { count: 0, total: 0, max: 0, last: 0 }; + bucket.count += 1; + bucket.total += amount; + bucket.max = Math.max(bucket.max, amount); + bucket.last = amount; + state.counters.set(metric, bucket); + state.lastUpdatedAt = Date.now(); +}; + +export const setStreamPerfEnabled = (enabled: boolean): void => { + if (typeof window === 'undefined') { + return; + } + + try { + if (enabled) { + window.localStorage.setItem(STREAM_PERF_STORAGE_KEY, '1'); + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + return; + } + + window.localStorage.removeItem(STREAM_PERF_STORAGE_KEY); + delete window.__openchamberStreamPerfState; + delete window.__openchamberVsCodeStreamPerfState; + } catch { + // ignore storage failures in debug helper + } +}; + +export const resetStreamPerf = (): void => { + if (typeof window === 'undefined') { + return; + } + + if (streamPerfEnabled()) { + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + } + + if (window.__openchamberVsCodeStreamPerfState) { + window.__openchamberVsCodeStreamPerfState = { + ...window.__openchamberVsCodeStreamPerfState, + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + } +}; + +export const getStreamPerfSnapshot = (): StreamPerfSnapshot => { + if (typeof window === 'undefined') { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const state = window.__openchamberStreamPerfState; + if (!streamPerfEnabled() || !state) { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + return { + enabled: true, + startedAt: state.startedAt, + lastUpdatedAt: state.lastUpdatedAt, + durationMs: Math.max(0, Date.now() - state.startedAt), + entries: normalizePerfEntries(state.counters), + }; +}; + +export const getVsCodeStreamPerfSnapshot = (): StreamPerfSnapshot => { + if (typeof window === 'undefined') { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const state = window.__openchamberVsCodeStreamPerfState; + if (!streamPerfEnabled() || !state) { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const startedAt = typeof state.startedAt === 'number' ? state.startedAt : null; + const lastUpdatedAt = typeof state.lastUpdatedAt === 'number' ? state.lastUpdatedAt : null; + return { + enabled: true, + startedAt, + lastUpdatedAt, + durationMs: startedAt ? Math.max(0, Date.now() - startedAt) : 0, + entries: normalizePerfEntries(state.counters), + }; +}; + +export const streamPerfCount = (metric: string, count = 1): void => { + updatePerfCounter(metric, count); +}; + +export const streamPerfObserve = (metric: string, value: number): void => { + updatePerfCounter(metric, value); +}; + +export const streamPerfMeasure = (metric: string, fn: () => T): T => { + if (!streamPerfEnabled()) { + return fn(); + } + + const start = nowMs(); + try { + return fn(); + } finally { + updatePerfCounter(metric, nowMs() - start); + } +}; diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md new file mode 100644 index 00000000..cebf8ab2 --- /dev/null +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -0,0 +1,217 @@ +# Sync architecture, event handling & store update rules + +## Scope + +This document covers the current client-side session/data architecture in `packages/ui/src/sync` and the rules for updating stores safely. + +There are **two distinct session data scopes** in the UI: + +1. **Directory-scoped sync stores** + - Owned by the sync layer child stores created in `sync-context.tsx` + - Source for per-directory live session/message/part/permission/question state + - Backed by SSE / directory-scoped polling + - Read via hooks like `useSessions()`, `useDirectorySync()`, `getSyncSessions()`, `getDirectoryState()` + +2. **Global sessions cache** + - Owned by `packages/ui/src/stores/useGlobalSessionsStore.ts` + - Shared source of truth for the Sessions sidebar global lists and Session Retention cleanup + - Holds: + - global active sessions + - global archived sessions + - active sessions indexed by directory + +These two scopes are intentionally different. + +### Why both exist + +The directory-scoped sync stores are **not** a complete global view. + +- They are created lazily per directory +- They only contain data for directories initialized in the current app session +- They are optimized for live per-directory domain data +- They do not maintain the complete global active+archived session view needed by the sidebar and retention settings + +So: + +- Use the **directory sync stores** for per-directory live session/message state +- Use the **global sessions store** for sidebar/retention global session lists + +## Ownership map + +| Layer / Store | Owns | Scope | +|---|---|---| +| child directory stores in `sync-context.tsx` | `session`, `message`, `part`, `permission`, `question`, etc. | One directory | +| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | +| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | +| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | +| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | +| `selection-store.ts` | Model/agent/variant selections | App UI state | +| `voice-store.ts` | Voice state | App UI state | + +## Session list rules + +### Directory-scoped session list + +Use the directory-scoped sync store when the UI needs the live session list for the **current directory**. + +Examples: + +- current chat/session switching +- per-directory session/message bootstrap +- session/message/part SSE updates + +### Global session list + +Use `useGlobalSessionsStore` when the UI needs a **shared global session view**. + +Current consumers: + +- `SessionSidebar.tsx` +- `useSessionAutoCleanup.ts` + +### Mutation responsibility + +`useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by: + +1. shared global fetch/reconciliation via `loadSessions()` / `refreshGlobalSessions()` +2. direct mutation from session actions after successful SDK calls: + - create + - title update + - share + - unshare + - archive + - delete + - retention cleanup batch archive/delete + +This keeps sidebar/retention UI responsive without requiring a refetch after every change. + +## Session action rules + +Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists. + +Rules: + +1. If an action mutates session list membership or visible session metadata, update `useGlobalSessionsStore` there. +2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. +3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. + +Examples of global-store updates performed in `session-actions.ts`: + +- `createSession()` -> `upsertSession(session)` +- `updateSessionTitle()` -> `upsertSession(result.data)` +- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` +- `archiveSession()` -> `archiveSessions([id], archivedAt)` +- `deleteSession()` -> `removeSessions([id])` + +## The golden rule + +When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. + +```typescript +// WRONG — clones everything, breaks referential equality for all subscribers +const draft = { + ...current, + session: [...current.session], + message: { ...current.message }, + part: { ...current.part }, + permission: { ...current.permission }, + // ... +} + +// RIGHT — only clone what this event type touches +const draft = { ...current } +switch (event.type) { + case "message.part.delta": + draft.part = { ...current.part } + break +} +``` + +## Why this matters + +Zustand skips re-renders when a selector returns the same reference (`Object.is`). If you spread `session: [...current.session]` but the event only modifies `part`, the `session` array gets a new reference. Every component using `useSessions()` re-renders for nothing. + +During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all fields caused every subscriber in the entire app to re-render 60/sec — a 10x overhead. Targeted cloning reduced MessageList renders from ~1972 to ~296 per session. + +## Event → field mapping + +Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`: + +| Event type | Fields to clone | +|---|---| +| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` | +| `session.diff` | `session_diff` | +| `session.status` | `session_status` | +| `todo.updated` | `todo` | +| `message.updated` | `message` | +| `message.removed` | `message`, `part` | +| `message.part.updated/removed/delta` | `part` | +| `vcs.branch.updated` | (none — mutates `draft.vcs` directly) | +| `permission.asked/replied` | `permission` | +| `question.asked/replied/rejected` | `question` | +| `lsp.updated` | `lsp` | + +## Adding a new event type + +1. Add the case to the event reducer (`event-reducer.ts`) +2. Add a corresponding case to the switch in `handleDirectoryEvent` (`sync-context.tsx`) that clones **only** the fields your reducer writes to +3. If your event fires frequently (more than a few times per second), verify that unrelated components don't re-render — check with the stream perf counters + +## Selector hygiene + +Select leaf values, not containers: + +```typescript +// WRONG — returns entire Map/object, new reference on any mutation +useDirectorySync((s) => s.permission) + +// RIGHT — returns the value for one key, stable unless that key changes +useDirectorySync((s) => s.permission[sessionID] ?? EMPTY) +``` + +Same applies to `useStreamingStore` — select `.get(key)` not the Map itself. + +## Store splitting pattern + +### Why split + +A single Zustand store with N properties means every subscriber's selector re-evaluates on every state change — even if the change is unrelated to what that subscriber reads. During streaming, `sessionMemoryState` updates ~60/sec. Before the split, all 68+ `useSessionUIStore` subscribers re-evaluated on each update. After splitting into focused stores, only `useViewportStore` subscribers (2-3 components) re-evaluate. + +The optimization multiplies with targeted event cloning: fewer new references per event × fewer subscribers per store = dramatically less work per SSE frame. + +### The stores + +| Store | Owns | When it changes | +|-------|------|-----------------| +| `session-ui-store.ts` | Session selection, draft lifecycle, abort, worktree, SDK actions | Session switch, draft open/close | +| `voice-store.ts` | Voice connection/activity state | Voice toggle | +| `input-store.ts` | Pending input text, synthetic parts, attached files | User typing, file attach, revert/fork | +| `selection-store.ts` | Per-session model/agent/variant choices | Model/agent picker | +| `viewport-store.ts` | Scroll anchors, session memory state, sync status | Streaming, scroll, session switch | + +### Rules for new UI state + +1. **Never add to `session-ui-store`** unless it's session selection, draft lifecycle, or abort state +2. **Group by change frequency** — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input) +3. **Group by subscriber set** — if only 2 components read a value, it should be in a store that only those 2 components subscribe to +4. **Prefer a new store over growing an existing one** if the new state has different subscribers or change frequency +5. **Cross-store reads use `.getState()`** — actions in one store that need to read another store call `useOtherStore.getState()` (imperative, no subscription) + +### Anti-patterns + +```typescript +// WRONG — stuffing unrelated state into one store +const useEverythingStore = create(() => ({ + voiceMode: "idle", + scrollAnchor: 0, + selectedModel: null, + pendingInput: "", + // 20 more fields... +})) + +// RIGHT — separate stores by concern + change frequency +const useVoiceStore = create(() => ({ voiceMode: "idle" })) +const useViewportStore = create(() => ({ scrollAnchor: 0 })) +const useSelectionStore = create(() => ({ selectedModel: null })) +const useInputStore = create(() => ({ pendingInput: "" })) +``` diff --git a/packages/ui/src/sync/binary.ts b/packages/ui/src/sync/binary.ts new file mode 100644 index 00000000..03c585cf --- /dev/null +++ b/packages/ui/src/sync/binary.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Binary { + export function search( + array: readonly T[], + id: string, + compare: (item: T) => string, + ): { found: boolean; index: number } { + let left = 0 + let right = array.length - 1 + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId === id) { + return { found: true, index: mid } + } else if (midId < id) { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return { found: false, index: left } + } + + export function insert(array: T[], item: T, compare: (item: T) => string): T[] { + const id = compare(item) + let left = 0 + let right = array.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId < id) { + left = mid + 1 + } else { + right = mid + } + } + + array.splice(left, 0, item) + return array + } +} diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts new file mode 100644 index 00000000..4ad2c4ce --- /dev/null +++ b/packages/ui/src/sync/bootstrap.ts @@ -0,0 +1,161 @@ +import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { retry } from "./retry" +import type { GlobalState, State } from "./types" + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function groupBySession(input: T[]) { + return input.reduce>((acc, item) => { + if (!item?.id || !item.sessionID) return acc + const list = acc[item.sessionID] + if (list) list.push(item) + else acc[item.sessionID] = [item] + return acc + }, {}) +} + +function projectID(directory: string, projects: Project[]) { + return projects.find( + (project) => project.worktree === directory || project.sandboxes?.includes(directory), + )?.id +} + +// --------------------------------------------------------------------------- +// Bootstrap global state +// --------------------------------------------------------------------------- + +export async function bootstrapGlobal( + sdk: OpencodeClient, + set: (patch: Partial) => void, +) { + const results = await Promise.allSettled([ + retry(() => sdk.path.get().then((x) => set({ path: x.data! }))), + retry(() => sdk.global.config.get().then((x) => set({ config: x.data! }))), + retry(() => + sdk.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p): p is Project => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .sort((a, b) => cmp(a.id, b.id)) + set({ projects }) + }), + ), + retry(() => sdk.provider.list().then((x) => set({ providers: x.data! }))), + ]) + + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + if (errors.length) { + console.error("[bootstrap] global bootstrap failed", errors[0]) + } + + set({ ready: true }) +} + +// --------------------------------------------------------------------------- +// Bootstrap per-directory state +// --------------------------------------------------------------------------- + +export async function bootstrapDirectory(input: { + directory: string + sdk: OpencodeClient + getState: () => State + set: (patch: Partial) => void + global: { + config: Record + projects: Project[] + providers: { all: unknown[]; connected: unknown[]; default: Record } + } + loadSessions: (directory: string) => Promise | void +}) { + const { directory, sdk, getState, set, global: g } = input + const state = getState() + const loading = state.status !== "complete" + + // Seed from global state while we fetch directory-specific data + const seededProject = projectID(directory, g.projects) + if (seededProject) set({ project: seededProject }) + if (state.provider.all.length === 0 && g.providers.all.length > 0) { + set({ provider: g.providers as State["provider"] }) + } + if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) { + set({ config: g.config as State["config"] }) + } + if (loading) set({ status: "partial" }) + + const results = await Promise.allSettled([ + seededProject + ? Promise.resolve() + : retry(() => sdk.project.current().then((x) => set({ project: x.data!.id }))), + retry(() => sdk.provider.list().then((x) => set({ provider: x.data! }))), + retry(() => sdk.app.agents().then((x) => set({ agent: x.data ?? [] }))), + retry(() => sdk.config.get().then((x) => set({ config: x.data! }))), + retry(() => + sdk.path.get().then((x) => { + set({ path: x.data! }) + const next = projectID(x.data?.directory ?? directory, g.projects) + if (next) set({ project: next }) + }), + ), + retry(() => sdk.command.list().then((x) => set({ command: x.data ?? [] }))), + retry(() => sdk.session.status().then((x) => set({ session_status: x.data! }))), + input.loadSessions(directory), + retry(() => sdk.mcp.status().then((x) => set({ mcp: x.data! }))), + retry(() => sdk.lsp.status().then((x) => set({ lsp: x.data! }))), + retry(() => + sdk.vcs.get().then((x) => { + const current = getState() + set({ vcs: x.data ?? current.vcs }) + }), + ), + retry(() => + sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + const permission: Record = {} + // Clear sessions no longer having permissions + const current = getState() + for (const sessionID of Object.keys(current.permission ?? {})) { + if (!grouped[sessionID]) permission[sessionID] = [] + } + // Set grouped permissions sorted by id + for (const [sessionID, perms] of Object.entries(grouped)) { + permission[sessionID] = perms + .filter((p) => !!p?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + set({ permission }) + }), + ), + retry(() => + sdk.question.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID), + ) + const question: Record = {} + const current = getState() + for (const sessionID of Object.keys(current.question ?? {})) { + if (!grouped[sessionID]) question[sessionID] = [] + } + for (const [sessionID, questions] of Object.entries(grouped)) { + question[sessionID] = questions + .filter((q) => !!q?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + set({ question }) + }), + ), + ]) + + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + if (errors.length) { + console.error(`[bootstrap] directory bootstrap failed for ${directory}`, errors[0]) + return + } + + if (loading) set({ status: "complete" }) +} diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts new file mode 100644 index 00000000..6e757d65 --- /dev/null +++ b/packages/ui/src/sync/child-store.ts @@ -0,0 +1,172 @@ +import { create, type StoreApi } from "zustand" +import type { DirState, State } from "./types" +import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types" +import { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction" +import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache" + +export type DirectoryStore = State & { + /** Apply a partial state update */ + patch: (partial: Partial) => void + /** Replace state wholesale (used during bootstrap) */ + replace: (next: State) => void +} + +function createDirectoryStore(directory: string): StoreApi { + // Restore cached metadata from localStorage + const cached = readDirCache(directory) + + const store = create()((set) => ({ + ...INITIAL_STATE, + vcs: cached.vcs ?? INITIAL_STATE.vcs, + projectMeta: cached.projectMeta ?? INITIAL_STATE.projectMeta, + icon: cached.icon ?? INITIAL_STATE.icon, + patch: (partial) => set(partial), + replace: (next) => set(next), + })) + + // Subscribe to persist metadata changes back to localStorage + store.subscribe((state, prev) => { + if (state.vcs !== prev.vcs) persistVcs(directory, state.vcs) + if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta) + if (state.icon !== prev.icon) persistIcon(directory, state.icon) + }) + + return store +} + +export class ChildStoreManager { + readonly children = new Map>() + private readonly lifecycle = new Map() + private readonly pins = new Map() + private readonly disposers = new Map void>() + + private onBootstrap?: (directory: string) => void + private onDispose?: (directory: string) => void + private isBooting?: (directory: string) => boolean + private isLoadingSessions?: (directory: string) => boolean + + configure(callbacks: { + onBootstrap?: (directory: string) => void + onDispose?: (directory: string) => void + isBooting?: (directory: string) => boolean + isLoadingSessions?: (directory: string) => boolean + }) { + this.onBootstrap = callbacks.onBootstrap + this.onDispose = callbacks.onDispose + this.isBooting = callbacks.isBooting + this.isLoadingSessions = callbacks.isLoadingSessions + } + + mark(directory: string) { + if (!directory) return + this.lifecycle.set(directory, { lastAccessAt: Date.now() }) + this.runEviction(directory) + } + + pin(directory: string) { + if (!directory) return + this.pins.set(directory, (this.pins.get(directory) ?? 0) + 1) + this.mark(directory) + } + + unpin(directory: string) { + if (!directory) return + const next = (this.pins.get(directory) ?? 0) - 1 + if (next > 0) { + this.pins.set(directory, next) + return + } + this.pins.delete(directory) + this.runEviction() + } + + pinned(directory: string) { + return (this.pins.get(directory) ?? 0) > 0 + } + + ensureChild(directory: string, options?: { bootstrap?: boolean }): StoreApi { + if (!directory) throw new Error("No directory provided to ensureChild") + + let store = this.children.get(directory) + if (!store) { + store = createDirectoryStore(directory) + this.children.set(directory, store) + } + + this.mark(directory) + + const shouldBootstrap = options?.bootstrap ?? true + if (shouldBootstrap && store.getState().status === "loading") { + this.onBootstrap?.(directory) + } + + return store + } + + getChild(directory: string): StoreApi | undefined { + return this.children.get(directory) + } + + disposeDirectory(directory: string): boolean { + if ( + !canDisposeDirectory({ + directory, + hasStore: this.children.has(directory), + pinned: this.pinned(directory), + booting: this.isBooting?.(directory) ?? false, + loadingSessions: this.isLoadingSessions?.(directory) ?? false, + }) + ) { + return false + } + + this.lifecycle.delete(directory) + this.children.delete(directory) + const dispose = this.disposers.get(directory) + if (dispose) { + dispose() + this.disposers.delete(directory) + } + this.onDispose?.(directory) + return true + } + + runEviction(skip?: string) { + const stores = [...this.children.keys()] + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: this.lifecycle, + pins: new Set(stores.filter((d) => this.pinned(d))), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }).filter((d) => d !== skip) + for (const directory of list) { + this.disposeDirectory(directory) + } + } + + /** Apply a state mutation to a directory's store */ + update(directory: string, fn: (state: State) => Partial) { + const store = this.children.get(directory) + if (!store) return + const current = store.getState() + const patch = fn(current) + store.setState(patch) + } + + /** Get current state of a directory store (snapshot) */ + getState(directory: string): State | undefined { + return this.children.get(directory)?.getState() + } + + disposeAll() { + for (const directory of [...this.children.keys()]) { + this.children.delete(directory) + } + this.lifecycle.clear() + this.pins.clear() + this.disposers.clear() + } +} diff --git a/packages/ui/src/sync/content-cache.ts b/packages/ui/src/sync/content-cache.ts new file mode 100644 index 00000000..4e552be8 --- /dev/null +++ b/packages/ui/src/sync/content-cache.ts @@ -0,0 +1,93 @@ +/** + * File content LRU cache — dual constraint eviction. + * Port of OpenCode's content-cache.ts. + * + * Evicts when either entry count exceeds MAX_FILE_CONTENT_ENTRIES + * or total byte estimate exceeds MAX_FILE_CONTENT_BYTES. + * Uses Map insertion order as LRU (oldest = first key). + */ + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 // 20 MB + +// LRU map: path → approximate bytes. Map insertion order = access order. +const lru = new Map() +let total = 0 + +/** Estimate byte size of a string (UTF-16 → ~2 bytes per char). */ +export function approxStringBytes(content: string): number { + return content.length * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +/** + * Evict entries until both constraints are satisfied. + * @param keep - paths to preserve (moved to end of LRU if encountered) + * @param evict - callback to actually remove content from the store + */ +export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) { + const safeSet = keep ?? new Set() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (safeSet.has(path)) { + touch(path) + if (lru.size <= safeSet.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetContentLru() { + lru.clear() + total = 0 +} + +export function setContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeContentBytes(path: string) { + remove(path) +} + +export function touchContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getContentBytesTotal(): number { + return total +} + +export function getContentEntryCount(): number { + return lru.size +} + +export function hasContent(path: string): boolean { + return lru.has(path) +} diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts new file mode 100644 index 00000000..f91e059e --- /dev/null +++ b/packages/ui/src/sync/event-pipeline.ts @@ -0,0 +1,221 @@ +/** + * Event Pipeline — SSE connection, event coalescing, and batched flush. + * + * Plain closure API: + * const { cleanup } = createEventPipeline({ sdk, onEvent }) + * + * No class, no start/stop lifecycle. One pipeline per mount. + * Abort controller created once at init, cleaned up via returned cleanup fn. + */ + +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type QueuedEvent = { + directory: string + payload: Event +} + +export type FlushHandler = (events: QueuedEvent[]) => void + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FLUSH_FRAME_MS = 16 +const STREAM_YIELD_MS = 8 +const RECONNECT_DELAY_MS = 250 +const HEARTBEAT_TIMEOUT_MS = 15_000 + +// --------------------------------------------------------------------------- +// Pipeline factory +// --------------------------------------------------------------------------- + +export function createEventPipeline(input: { + sdk: OpencodeClient + onEvent: (directory: string, payload: Event) => void +}) { + const { sdk, onEvent } = input + const abort = new AbortController() + + // Queue state + let queue: QueuedEvent[] = [] + let buffer: QueuedEvent[] = [] + const coalesced = new Map() + const staleDeltas = new Set() + let timer: ReturnType | undefined + let last = 0 + + const deltaKey = (directory: string, messageID: string, partID: string) => + `${directory}:${messageID}:${partID}` + + // Coalesce key — same-type events for the same entity replace earlier ones + const key = (directory: string, payload: Event): string | undefined => { + if (payload.type === "session.status") { + const props = payload.properties as { sessionID: string } + return `session.status:${directory}:${props.sessionID}` + } + if (payload.type === "lsp.updated") { + return `lsp.updated:${directory}` + } + if (payload.type === "message.part.updated") { + const part = (payload.properties as { part: { messageID: string; id: string } }).part + return `message.part.updated:${directory}:${part.messageID}:${part.id}` + } + return undefined + } + + // Flush — swap queue, dispatch events, skip stale deltas + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + + if (queue.length === 0) return + + const events = queue + const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined + queue = buffer + buffer = events + queue.length = 0 + coalesced.clear() + staleDeltas.clear() + + last = Date.now() + // React 18 batches synchronous setState calls automatically, + // equivalent to SolidJS batch() + for (const event of events) { + if (skip && event.payload.type === "message.part.delta") { + const props = event.payload.properties as { messageID: string; partID: string } + if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue + } + onEvent(event.directory, event.payload) + } + + buffer.length = 0 + } + + const schedule = () => { + if (timer) return + const elapsed = Date.now() - last + timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) + } + + // Helpers + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const isAbortError = (error: unknown): boolean => + error instanceof DOMException && error.name === "AbortError" || + (typeof error === "object" && error !== null && (error as { name?: string }).name === "AbortError") + + let streamErrorLogged = false + let attempt: AbortController | undefined + let lastEventAt = Date.now() + let heartbeat: ReturnType | undefined + + const resetHeartbeat = () => { + lastEventAt = Date.now() + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt?.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + + const clearHeartbeat = () => { + if (!heartbeat) return + clearTimeout(heartbeat) + heartbeat = undefined + } + + // SSE loop — iterate SDK global event stream, enqueue with coalescing + void (async () => { + while (!abort.signal.aborted) { + attempt = new AbortController() + lastEventAt = Date.now() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) + + try { + const events = await sdk.global.event({ + signal: attempt.signal, + onSseError: (error: unknown) => { + if (isAbortError(error)) return + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[event-pipeline] stream error", error) + }, + }) + + let yielded = Date.now() + resetHeartbeat() + + // Enqueue event with coalescing + stale delta tracking + for await (const event of events.stream) { + resetHeartbeat() + streamErrorLogged = false + const directory = (event as { directory?: string }).directory ?? "global" + const payload = (event as { payload?: Event }).payload ?? (event as unknown as Event) + if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") { + continue + } + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = { directory, payload } + if (payload.type === "message.part.updated") { + const part = (payload.properties as { part: { messageID: string; id: string } }).part + staleDeltas.add(deltaKey(directory, part.messageID, part.id)) + } + continue + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!isAbortError(error) && !streamErrorLogged) { + streamErrorLogged = true + console.error("[event-pipeline] stream failed", error) + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + clearHeartbeat() + } + + if (abort.signal.aborted) return + await wait(RECONNECT_DELAY_MS) + } + })().finally(flush) + + // Visibility handler — flush immediately when tab becomes visible + const onVisibility = () => { + if (typeof document === "undefined") return + if (document.visibilityState !== "visible") return + if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return + attempt?.abort() + } + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility) + } + + // Cleanup — abort SSE, flush remaining events, remove listeners + const cleanup = () => { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility) + } + abort.abort() + flush() + } + + return { cleanup } +} diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts new file mode 100644 index 00000000..6d710e7c --- /dev/null +++ b/packages/ui/src/sync/event-reducer.ts @@ -0,0 +1,345 @@ +import type { + Event, + FileDiff, + Message, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import type { GlobalState, State } from "./types" +import { dropSessionCaches } from "./session-cache" +import { stripSessionDiffSnapshots } from "./sanitize" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + +// --------------------------------------------------------------------------- +// Global events +// --------------------------------------------------------------------------- + +export type GlobalEventResult = { + type: "refresh" +} | { + type: "project" + project: Project +} | null + +export function reduceGlobalEvent(event: Event): GlobalEventResult { + if (event.type === "global.disposed" || event.type === "server.connected") { + return { type: "refresh" } + } + if (event.type === "project.updated") { + return { type: "project", project: event.properties as Project } + } + return null +} + +export function applyGlobalProject(state: GlobalState, project: Project): GlobalState { + const projects = [...state.projects] + const result = Binary.search(projects, project.id, (s) => s.id) + if (result.found) { + projects[result.index] = { ...projects[result.index], ...project } + } else { + projects.splice(result.index, 0, project) + } + return { ...state, projects } +} + +// --------------------------------------------------------------------------- +// Directory events — mutates draft in place for batching efficiency. +// Caller MUST pass a mutable copy of State (e.g. structuredClone or spread). +// --------------------------------------------------------------------------- + +export function applyDirectoryEvent( + draft: State, + event: Event, + callbacks?: { + onRefresh?: (directory: string) => void + onLoadLsp?: () => void + onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void + }, +): boolean { + switch (event.type) { + case "server.instance.disposed": { + callbacks?.onRefresh?.("") + return false + } + + case "session.created": { + const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + if (result.found) { + sessions[result.index] = info + } else { + sessions.splice(result.index, 0, info) + trimSessions(draft) + if (!info.parentID) draft.sessionTotal += 1 + } + return true + } + + case "session.updated": { + const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + + if (info.time.archived) { + if (result.found) sessions.splice(result.index, 1) + cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) + if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) + return true + } + + if (result.found) { + sessions[result.index] = info + } else { + sessions.splice(result.index, 0, info) + trimSessions(draft) + } + return true + } + + case "session.deleted": { + const info = (event.properties as { info: Session }).info + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + if (result.found) sessions.splice(result.index, 1) + cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) + if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) + return true + } + + case "session.diff": { + const props = event.properties as { sessionID: string; diff: FileDiff[] } + draft.session_diff[props.sessionID] = props.diff + return true + } + + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + draft.todo[props.sessionID] = props.todos + callbacks?.onSetSessionTodo?.(props.sessionID, props.todos) + return true + } + + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + draft.session_status[props.sessionID] = props.status + return true + } + + case "message.updated": { + const info = (event.properties as { info: Message }).info + const messages = draft.message[info.sessionID] + if (!messages) { + draft.message[info.sessionID] = [info] + return true + } + const result = Binary.search(messages, info.id, (m) => m.id) + if (result.found) { + // Skip message replacement if unchanged — preserves reference, avoids re-render + const existing = messages[result.index] + const unchanged = existing.role === info.role + && (existing as { finish?: unknown }).finish === (info as { finish?: unknown }).finish + && (existing.time as { completed?: number })?.completed === (info.time as { completed?: number })?.completed + if (unchanged) { + return false + } + const next = [...messages] + next[result.index] = info + draft.message[info.sessionID] = next + } else { + const next = [...messages] + next.splice(result.index, 0, info) + draft.message[info.sessionID] = next + } + return true + } + + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + const messages = draft.message[props.sessionID] + if (messages) { + const next = [...messages] + const result = Binary.search(next, props.messageID, (m) => m.id) + if (result.found) { + next.splice(result.index, 1) + draft.message[props.sessionID] = next + } + } + delete draft.part[props.messageID] + return true + } + + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) return false + const messageID = (part as { messageID: string }).messageID + const parts = draft.part[messageID] + if (!parts) { + draft.part[messageID] = [part] + return true + } + const next = [...parts] + const result = Binary.search(next, part.id, (p) => p.id) + if (result.found) { + next[result.index] = part + } else { + // Replace optimistic part (no sessionID) with server part of same type. + // Gate: only scan if the first part lacks sessionID (optimistic parts are + // always inserted first). Assistant messages never have optimistic parts, + // so this check is effectively free during streaming. + const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID + const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file") + ? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID) + : -1 + if (optimisticIdx >= 0) { + next.splice(optimisticIdx, 1) + } + const insertResult = Binary.search(next, part.id, (p) => p.id) + next.splice(insertResult.index, 0, part) + } + draft.part[messageID] = next + return true + } + + case "message.part.removed": { + const props = event.properties as { messageID: string; partID: string } + const parts = draft.part[props.messageID] + if (!parts) return false + const result = Binary.search(parts, props.partID, (p) => p.id) + if (result.found) { + const next = [...parts] + next.splice(result.index, 1) + if (next.length === 0) { + delete draft.part[props.messageID] + } else { + draft.part[props.messageID] = next + } + return true + } + return false + } + + case "message.part.delta": { + const props = event.properties as { + messageID: string + partID: string + field: string + delta: string + } + const parts = draft.part[props.messageID] + if (!parts) return false + const result = Binary.search(parts, props.partID, (p) => p.id) + if (!result.found) return false + const existing = parts[result.index] as Record + const existingValue = existing[props.field] as string | undefined + // Create new Part object + new array so React detects the change + const next = [...parts] + next[result.index] = { ...existing, [props.field]: (existingValue ?? "") + props.delta } as Part + draft.part[props.messageID] = next + return true + } + + case "vcs.branch.updated": { + const props = event.properties as { branch: string } + if (draft.vcs?.branch === props.branch) return false + draft.vcs = { branch: props.branch } + return true + } + + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = draft.permission[permission.sessionID] ?? [] + draft.permission[permission.sessionID] = permissions + const result = Binary.search(permissions, permission.id, (p) => p.id) + if (result.found) { + permissions[result.index] = permission + } else { + permissions.splice(result.index, 0, permission) + } + return true + } + + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + const permissions = draft.permission[props.sessionID] + if (!permissions) return false + const result = Binary.search(permissions, props.requestID, (p) => p.id) + if (result.found) { + permissions.splice(result.index, 1) + return true + } + return false + } + + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = draft.question[question.sessionID] ?? [] + draft.question[question.sessionID] = questions + const result = Binary.search(questions, question.id, (q) => q.id) + if (result.found) { + questions[result.index] = question + } else { + questions.splice(result.index, 0, question) + } + return true + } + + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + const questions = draft.question[props.sessionID] + if (!questions) return false + const result = Binary.search(questions, props.requestID, (q) => q.id) + if (result.found) { + questions.splice(result.index, 1) + return true + } + return false + } + + case "lsp.updated": { + callbacks?.onLoadLsp?.() + return false + } + + default: + return false + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function trimSessions(draft: State) { + if (draft.session.length <= draft.limit) return + // Keep sessions that have pending permissions (they need to stay visible) + const hasPermission = new Set( + Object.entries(draft.permission ?? {}) + .filter(([, perms]) => perms && perms.length > 0) + .map(([sessionID]) => sessionID), + ) + while (draft.session.length > draft.limit) { + // Remove from the beginning (oldest by sorted ID) + const candidate = draft.session[0] + if (hasPermission.has(candidate.id)) break + draft.session.shift() + } +} + +function cleanupSessionCaches( + draft: State, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + if (!sessionID) return + setSessionTodo?.(sessionID, undefined) + dropSessionCaches(draft, [sessionID]) +} diff --git a/packages/ui/src/sync/eviction.ts b/packages/ui/src/sync/eviction.ts new file mode 100644 index 00000000..676a6ee1 --- /dev/null +++ b/packages/ui/src/sync/eviction.ts @@ -0,0 +1,28 @@ +import type { DisposeCheck, EvictPlan } from "./types" + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} diff --git a/packages/ui/src/sync/global-sync-store.ts b/packages/ui/src/sync/global-sync-store.ts new file mode 100644 index 00000000..52dea534 --- /dev/null +++ b/packages/ui/src/sync/global-sync-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand" +import type { GlobalState } from "./types" +import { INITIAL_GLOBAL_STATE } from "./types" + +export type GlobalSyncStore = GlobalState & { + actions: { + set: (patch: Partial) => void + reset: () => void + } +} + +export const useGlobalSyncStore = create()((set) => ({ + ...INITIAL_GLOBAL_STATE, + actions: { + set: (patch) => set(patch), + reset: () => set(INITIAL_GLOBAL_STATE), + }, +})) + +// Fine-grained selectors — use these in components for minimal re-renders +export const selectReady = (s: GlobalSyncStore) => s.ready +export const selectProjects = (s: GlobalSyncStore) => s.projects +export const selectProviders = (s: GlobalSyncStore) => s.providers +export const selectConfig = (s: GlobalSyncStore) => s.config +export const selectPath = (s: GlobalSyncStore) => s.path +export const selectReload = (s: GlobalSyncStore) => s.reload +export const selectSessionTodo = (s: GlobalSyncStore) => s.sessionTodo diff --git a/packages/ui/src/sync/index.ts b/packages/ui/src/sync/index.ts new file mode 100644 index 00000000..ae915772 --- /dev/null +++ b/packages/ui/src/sync/index.ts @@ -0,0 +1,152 @@ +// Core utilities +export { Binary } from "./binary" +export { retry, type RetryOptions } from "./retry" + +// Types +export type { State, GlobalState, ProjectMeta, DirState, EvictPlan, DisposeCheck, ChildOptions } from "./types" +export { + INITIAL_STATE, + INITIAL_GLOBAL_STATE, + MAX_DIR_STORES, + DIR_IDLE_TTL_MS, + SESSION_CACHE_LIMIT, + SESSION_RECENT_LIMIT, + SESSION_RECENT_WINDOW, +} from "./types" + +// Eviction +export { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction" + +// Session cache +export { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +// Optimistic +export { + applyOptimisticAdd, + applyOptimisticRemove, + mergeOptimisticPage, + mergeMessages, + type OptimisticItem, + type OptimisticStore, + type OptimisticAddInput, + type OptimisticRemoveInput, + type MessagePage, +} from "./optimistic" + +// Event reducer +export { + reduceGlobalEvent, + applyGlobalProject, + applyDirectoryEvent, + type GlobalEventResult, +} from "./event-reducer" + +// Event pipeline +export { createEventPipeline, type QueuedEvent, type FlushHandler } from "./event-pipeline" + +// Stores +export { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" +export { ChildStoreManager, type DirectoryStore } from "./child-store" + +// Bootstrap +export { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" + +// React integration +export { + SyncProvider, + useGlobalSync, + useGlobalSyncSelector, + useDirectoryStore, + useDirectorySync, + useSessionMessages, + useSessionParts, + useSessionStatus, + useSessionPermissions, + useSessionQuestions, + useSessions, + useSyncSDK, + useSyncDirectory, + useChildStoreManager, + useSessionMessageRecords, +} from "./sync-context" + +// Sync operations +export { useSync } from "./use-sync" + +// Prompt submission +export { usePromptSubmit, type SubmitInput } from "./submit" + + +// Streaming lifecycle +export { + useStreamingStore, + updateStreamingState, + selectStreamingMessageId, + selectMessageStreamState, + selectIsStreaming, + type StreamPhase, + type MessageStreamState, + type StreamingStore, +} from "./streaming" + +// Session UI state +export { + useSessionUIStore, + type SessionUIState, + type AttachedFile, + type NewSessionDraftState, +} from "./session-ui-store" + +// Input store (pending input, synthetic parts, attached files) +export { useInputStore, type SyntheticContextPart } from "./input-store" + +// Viewport store (per-session scroll anchors, memory state) +export { + useViewportStore, + type SessionMemoryState, + type ViewportState, +} from "./viewport-store" + +// Sync refs (imperative access from non-React code) +export { + setSyncRefs, + getSyncSDK, + getSyncChildStores, + getSyncDirectory, + getDirectoryState, + getSyncSessions, + getSyncMessages, + getSyncParts, + getSyncSessionStatus, + getSyncPermissions, + getSyncQuestions, +} from "./sync-refs" + +// Persisted metadata caches +export { + readDirCache, + persistVcs, + persistProjectMeta, + persistIcon, + clearDirCache, + type PersistedDirCache, +} from "./persist-cache" + +// Session actions +export { + setActionRefs, + createSession, + deleteSession, + archiveSession, + updateSessionTitle, + shareSession, + unshareSession, + optimisticSend, + abortCurrentOperation, + respondToPermission, + dismissPermission, + respondToQuestion, + rejectQuestion, + revertToMessage, + forkFromMessage, +} from "./session-actions" diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts new file mode 100644 index 00000000..cc61897e --- /dev/null +++ b/packages/ui/src/sync/input-store.ts @@ -0,0 +1,79 @@ +/** + * Input Store — pending input text, synthetic parts, and attached files. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" +import type { AttachedFile } from "@/stores/types/sessionTypes" + +export type SyntheticContextPart = { + text: string + attachments?: AttachedFile[] + synthetic?: boolean +} + +export type InputState = { + pendingInputText: string | null + pendingInputMode: "replace" | "append" | "append-inline" + pendingSyntheticParts: SyntheticContextPart[] | null + attachedFiles: AttachedFile[] + + setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void + consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null + setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void + consumePendingSyntheticParts: () => SyntheticContextPart[] | null + addAttachedFile: (file: File) => Promise + removeAttachedFile: (id: string) => void + clearAttachedFiles: () => void +} + +export const useInputStore = create()((set, get) => ({ + pendingInputText: null, + pendingInputMode: "replace", + pendingSyntheticParts: null, + attachedFiles: [], + + setPendingInputText: (text, mode = "replace") => + set({ pendingInputText: text, pendingInputMode: mode }), + + consumePendingInputText: () => { + const { pendingInputText, pendingInputMode } = get() + if (pendingInputText === null) return null + set({ pendingInputText: null, pendingInputMode: "replace" }) + return { text: pendingInputText, mode: pendingInputMode } + }, + + setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }), + + consumePendingSyntheticParts: () => { + const { pendingSyntheticParts } = get() + if (pendingSyntheticParts !== null) { + set({ pendingSyntheticParts: null }) + } + return pendingSyntheticParts + }, + + addAttachedFile: async (file: File) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.readAsDataURL(file) + }) + const attached: AttachedFile = { + id, + file, + dataUrl, + mimeType: file.type, + filename: file.name, + size: file.size, + source: "local", + } + set((s) => ({ attachedFiles: [...s.attachedFiles, attached] })) + }, + + removeAttachedFile: (id) => + set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })), + + clearAttachedFiles: () => set({ attachedFiles: [] }), +})) diff --git a/packages/ui/src/sync/notification-store.ts b/packages/ui/src/sync/notification-store.ts new file mode 100644 index 00000000..1501c7b7 --- /dev/null +++ b/packages/ui/src/sync/notification-store.ts @@ -0,0 +1,170 @@ +// --------------------------------------------------------------------------- +// Notification store — session turn-complete and error tracking +// +// Tracks session turn-complete and error notifications with viewed/unviewed +// state. Replaces the old sessionAttentionStates polling system. +// --------------------------------------------------------------------------- + +import { create } from "zustand" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type NotificationBase = { + directory?: string + session?: string + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error?: { message?: string; code?: string } +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +type NotificationIndex = { + session: { + unseenCount: Record + unseenHasError: Record + } + project: { + unseenCount: Record + unseenHasError: Record + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_NOTIFICATIONS = 500 +const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 days + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pruneNotifications(list: Notification[]): Notification[] { + const cutoff = Date.now() - NOTIFICATION_TTL_MS + const pruned = list.filter((n) => n.time >= cutoff) + if (pruned.length <= MAX_NOTIFICATIONS) return pruned + return pruned.slice(pruned.length - MAX_NOTIFICATIONS) +} + +function buildIndex(list: Notification[]): NotificationIndex { + const index: NotificationIndex = { + session: { unseenCount: {}, unseenHasError: {} }, + project: { unseenCount: {}, unseenHasError: {} }, + } + + for (const n of list) { + if (n.viewed) continue + + if (n.session) { + index.session.unseenCount[n.session] = (index.session.unseenCount[n.session] ?? 0) + 1 + if (n.type === "error") index.session.unseenHasError[n.session] = true + } + if (n.directory) { + index.project.unseenCount[n.directory] = (index.project.unseenCount[n.directory] ?? 0) + 1 + if (n.type === "error") index.project.unseenHasError[n.directory] = true + } + } + + return index +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +interface NotificationStore { + list: Notification[] + index: NotificationIndex + + // Mutations + append: (notification: Notification) => void + markSessionViewed: (sessionId: string) => void + markProjectViewed: (directory: string) => void + + // Selectors + sessionUnseenCount: (sessionId: string) => number + sessionHasError: (sessionId: string) => boolean + projectUnseenCount: (directory: string) => number + projectHasError: (directory: string) => boolean +} + +export const useNotificationStore = create((set, get) => ({ + list: [], + index: { + session: { unseenCount: {}, unseenHasError: {} }, + project: { unseenCount: {}, unseenHasError: {} }, + }, + + append: (notification) => { + const current = get().list + const next = pruneNotifications([...current, notification]) + set({ list: next, index: buildIndex(next) }) + }, + + markSessionViewed: (sessionId) => { + const current = get() + const count = current.index.session.unseenCount[sessionId] ?? 0 + if (count === 0) return + + const next = current.list.map((n) => + n.session === sessionId && !n.viewed ? { ...n, viewed: true } : n, + ) + set({ list: next, index: buildIndex(next) }) + }, + + markProjectViewed: (directory) => { + const current = get() + const count = current.index.project.unseenCount[directory] ?? 0 + if (count === 0) return + + const next = current.list.map((n) => + n.directory === directory && !n.viewed ? { ...n, viewed: true } : n, + ) + set({ list: next, index: buildIndex(next) }) + }, + + sessionUnseenCount: (sessionId) => get().index.session.unseenCount[sessionId] ?? 0, + sessionHasError: (sessionId) => get().index.session.unseenHasError[sessionId] ?? false, + projectUnseenCount: (directory) => get().index.project.unseenCount[directory] ?? 0, + projectHasError: (directory) => get().index.project.unseenHasError[directory] ?? false, +})) + +// --------------------------------------------------------------------------- +// Imperative API for non-React code (event handler in sync-context) +// --------------------------------------------------------------------------- + +export function appendNotification(notification: Notification) { + useNotificationStore.getState().append(notification) +} + +export function markSessionViewed(sessionId: string) { + useNotificationStore.getState().markSessionViewed(sessionId) +} + +// --------------------------------------------------------------------------- +// React hooks for fine-grained subscriptions +// --------------------------------------------------------------------------- + +export function useSessionUnseenCount(sessionId: string): number { + return useNotificationStore((s) => s.index.session.unseenCount[sessionId] ?? 0) +} + +export function useSessionHasError(sessionId: string): boolean { + return useNotificationStore((s) => s.index.session.unseenHasError[sessionId] ?? false) +} + +export function useProjectUnseenCount(directory: string): number { + return useNotificationStore((s) => s.index.project.unseenCount[directory] ?? 0) +} diff --git a/packages/ui/src/sync/optimistic.ts b/packages/ui/src/sync/optimistic.ts new file mode 100644 index 00000000..ac1b5a48 --- /dev/null +++ b/packages/ui/src/sync/optimistic.ts @@ -0,0 +1,127 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +export type OptimisticStore = { + message: Record + part: Record +} + +export type OptimisticItem = { + message: Message + parts: Part[] +} + +export type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +export type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +export type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +const hasParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return want.length === 0 + return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +} + +const mergeParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return sortParts(want) + const next = [...parts] + let changed = false + for (const part of want) { + const result = Binary.search(next, part.id, (item) => item.id) + if (result.found) continue + next.splice(result.index, 0, part) + changed = true + } + if (!changed) return parts + return next +} + +export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, confirmed: [] as string[] } + + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) + const confirmed: string[] = [] + + for (const item of items) { + const result = Binary.search(session, item.message.id, (message) => message.id) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + + const current = part.get(item.message.id) + if (found && hasParts(current, item.parts)) { + confirmed.push(item.message.id) + continue + } + + part.set(item.message.id, mergeParts(current, item.parts)) + } + + return { + cursor: page.cursor, + complete: page.complete, + session, + part: [...part.entries()] + .sort((a, b) => cmp(a[0], b[0])) + .map(([id, part]) => ({ id, part })), + confirmed, + } +} + +/** Apply optimistic add to a mutable draft (for immer/produce) */ +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.message.id, (m) => m.id) + if (!result.found) { + messages.splice(result.index, 0, input.message) + } + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +/** Apply optimistic remove to a mutable draft (for immer/produce) */ +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[input.messageID] +} + +/** Merge two sorted message arrays by id, deduplicating. + * Preserves references from `a` for items that already exist — avoids + * unnecessary React re-renders when prepending older history. */ +export function mergeMessages(a: readonly T[], b: readonly T[]) { + const existing = new Map(a.map((item) => [item.id, item] as const)) + let changed = false + for (const item of b) { + if (!existing.has(item.id)) { + existing.set(item.id, item) + changed = true + } + } + if (!changed) return a as T[] + return [...existing.values()].sort((x, y) => cmp(x.id, y.id)) +} diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts new file mode 100644 index 00000000..7be0cafe --- /dev/null +++ b/packages/ui/src/sync/persist-cache.ts @@ -0,0 +1,116 @@ +/** + * Persisted child-store metadata caches. + * + * VCS info, project metadata, and icons are cached to localStorage + * per directory so they survive page reloads. + * Only metadata is persisted — session/message/part data is always fresh + * from the server via SSE bootstrap. + */ + +import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { ProjectMeta } from "./types" + +// --------------------------------------------------------------------------- +// Storage key generation +// --------------------------------------------------------------------------- + +function hashCode(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const chr = str.charCodeAt(i) + hash = ((hash << 5) - hash) + chr + hash |= 0 + } + return Math.abs(hash).toString(36) +} + +function storagePrefix(directory: string): string { + const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_") + return `oc.dir.${head}.${hashCode(directory)}` +} + +// --------------------------------------------------------------------------- +// Typed cache helpers +// --------------------------------------------------------------------------- + +type CacheKey = "vcs" | "projectMeta" | "icon" + +function cacheKey(directory: string, key: CacheKey): string { + return `${storagePrefix(directory)}.${key}` +} + +function readCache(directory: string, key: CacheKey): T | undefined { + try { + const raw = localStorage.getItem(cacheKey(directory, key)) + if (!raw) return undefined + return JSON.parse(raw) as T + } catch { + return undefined + } +} + +function writeCache(directory: string, key: CacheKey, value: T | undefined): void { + try { + const k = cacheKey(directory, key) + if (value === undefined) { + localStorage.removeItem(k) + } else { + localStorage.setItem(k, JSON.stringify(value)) + } + } catch { + // localStorage quota exceeded — ignore + } +} + +function clearCache(directory: string): void { + try { + const prefix = storagePrefix(directory) + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k?.startsWith(prefix)) keys.push(k) + } + for (const k of keys) localStorage.removeItem(k) + } catch { + // ignore + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type PersistedDirCache = { + vcs: VcsInfo | undefined + projectMeta: ProjectMeta | undefined + icon: string | undefined +} + +/** Read all cached metadata for a directory */ +export function readDirCache(directory: string): PersistedDirCache { + return { + vcs: readCache(directory, "vcs"), + projectMeta: readCache(directory, "projectMeta"), + icon: readCache(directory, "icon"), + } +} + +/** Write vcs info to cache */ +export function persistVcs(directory: string, vcs: VcsInfo | undefined): void { + writeCache(directory, "vcs", vcs) +} + +/** Write project metadata to cache */ +export function persistProjectMeta(directory: string, meta: ProjectMeta | undefined): void { + writeCache(directory, "projectMeta", meta) +} + +/** Write icon to cache */ +export function persistIcon(directory: string, icon: string | undefined): void { + writeCache(directory, "icon", icon) +} + +/** Clear all cached metadata for a directory */ +export function clearDirCache(directory: string): void { + clearCache(directory) +} diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts new file mode 100644 index 00000000..7d580407 --- /dev/null +++ b/packages/ui/src/sync/retry.ts @@ -0,0 +1,54 @@ +export interface RetryOptions { + attempts?: number + delay?: number + factor?: number + maxDelay?: number + retryIf?: (error: unknown) => boolean +} + +const TRANSIENT_MESSAGES = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", + "opencode api unavailable", + "503", + "502", +] + +function isTransientError(error: unknown): boolean { + if (!error) return false + const message = String(error instanceof Error ? error.message : error).toLowerCase() + if (TRANSIENT_MESSAGES.some((m) => message.includes(m))) return true + // SDK errors from HTTP 502/503 responses (VS Code bridge returns these before OpenCode is ready) + const status = (error as { status?: number })?.status + if (status === 502 || status === 503) return true + return false +} + +export async function retry(fn: () => Promise, options: RetryOptions = {}): Promise { + const { + attempts = 3, + delay = 500, + factor = 2, + maxDelay = 10000, + retryIf = isTransientError, + } = options + + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === attempts - 1 || !retryIf(error)) throw error + const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + throw lastError +} diff --git a/packages/ui/src/sync/sanitize.ts b/packages/ui/src/sync/sanitize.ts new file mode 100644 index 00000000..9e6aa195 --- /dev/null +++ b/packages/ui/src/sync/sanitize.ts @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------- +// Payload sanitization — strip oversized diff snapshot fields client-side. +// +// OpenCode session objects carry summary.diffs[].before/after with full file +// contents. The UI never uses these fields but they waste browser memory and +// can crash tabs for large sessions. +// +// Applied at two points: +// 1. Event reducer — session.created/session.updated events +// 2. Message loading — fetchMessages response +// --------------------------------------------------------------------------- + +import type { Session, Message } from "@opencode-ai/sdk/v2/client" + +type DiffEntry = { + file?: string + status?: string + additions?: number + deletions?: number + before?: string + after?: string +} + +type SessionSummary = { + diffs?: DiffEntry[] + [key: string]: unknown +} + +/** Strip before/after from summary.diffs on a session object */ +export function stripSessionDiffSnapshots(session: Session): Session { + const summary = (session as { summary?: SessionSummary }).summary + if (!summary?.diffs || !Array.isArray(summary.diffs)) return session + + let changed = false + const stripped = summary.diffs.map((d) => { + if (d && (typeof d.before === "string" || typeof d.after === "string")) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before: _before, after: _after, ...rest } = d + changed = true + return rest + } + return d + }) + + if (!changed) return session + return { ...session, summary: { ...summary, diffs: stripped } } as Session +} + +/** Strip before/after from summary.diffs on a message object */ +export function stripMessageDiffSnapshots(message: Message): Message { + const summary = (message as { summary?: SessionSummary }).summary + if (!summary?.diffs || !Array.isArray(summary.diffs)) return message + + let changed = false + const stripped = summary.diffs.map((d) => { + if (d && (typeof d.before === "string" || typeof d.after === "string")) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before: _before, after: _after, ...rest } = d + changed = true + return rest + } + return d + }) + + if (!changed) return message + return { ...message, summary: { ...summary, diffs: stripped } } as Message +} diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts new file mode 100644 index 00000000..d53fae56 --- /dev/null +++ b/packages/ui/src/sync/selection-store.ts @@ -0,0 +1,86 @@ +/** + * Selection Store — per-session model, agent, and variant selections. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type SelectionState = { + sessionModelSelections: Map + sessionAgentSelections: Map + sessionAgentModelSelections: Map> + lastUsedProvider: { providerID: string; modelID: string } | null + + saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void + getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null + saveSessionAgentSelection: (sessionId: string, agentName: string) => void + getSessionAgentSelection: (sessionId: string) => string | null + saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void + getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null + saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void + getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined +} + +// In-memory variant storage (not persisted) +const agentModelVariantSelections = new Map>>() + +export const useSelectionStore = create()((set, get) => ({ + sessionModelSelections: new Map(), + sessionAgentSelections: new Map(), + sessionAgentModelSelections: new Map(), + lastUsedProvider: null, + + saveSessionModelSelection: (sessionId, providerId, modelId) => + set((s) => { + const map = new Map(s.sessionModelSelections) + map.set(sessionId, { providerId, modelId }) + return { sessionModelSelections: map, lastUsedProvider: { providerID: providerId, modelID: modelId } } + }), + + getSessionModelSelection: (sessionId) => get().sessionModelSelections.get(sessionId) ?? null, + + saveSessionAgentSelection: (sessionId, agentName) => + set((s) => { + if (s.sessionAgentSelections.get(sessionId) === agentName) return s + const map = new Map(s.sessionAgentSelections) + map.set(sessionId, agentName) + return { sessionAgentSelections: map } + }), + + getSessionAgentSelection: (sessionId) => get().sessionAgentSelections.get(sessionId) ?? null, + + saveAgentModelForSession: (sessionId, agentName, providerId, modelId) => + set((s) => { + const existing = s.sessionAgentModelSelections.get(sessionId)?.get(agentName) + if (existing?.providerId === providerId && existing?.modelId === modelId) return s + const outer = new Map(s.sessionAgentModelSelections) + const inner = new Map(outer.get(sessionId) ?? new Map()) + inner.set(agentName, { providerId, modelId }) + outer.set(sessionId, inner) + return { sessionAgentModelSelections: outer } + }), + + getAgentModelForSession: (sessionId, agentName) => + get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null, + + saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => { + if (!variant) return + const key = `${providerId}/${modelId}` + let agentMap = agentModelVariantSelections.get(sessionId) + if (!agentMap) { + agentMap = new Map() + agentModelVariantSelections.set(sessionId, agentMap) + } + let modelMap = agentMap.get(agentName) + if (!modelMap) { + modelMap = new Map() + agentMap.set(agentName, modelMap) + } + modelMap.set(key, variant) + }, + + getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => { + const key = `${providerId}/${modelId}` + return agentModelVariantSelections.get(sessionId)?.get(agentName)?.get(key) + }, +})) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts new file mode 100644 index 00000000..525ecc0e --- /dev/null +++ b/packages/ui/src/sync/session-actions.ts @@ -0,0 +1,586 @@ +/** + * Session actions — SDK-calling operations for session management. + * Replaces the action methods from the old useSessionStore. + */ + +import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import { useSessionUIStore } from "./session-ui-store" +import { useInputStore } from "./input-store" +import type { DirectoryStore } from "./child-store" +import type { StoreApi } from "zustand" +import { opencodeClient } from "@/lib/opencode/client" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" + +// Reference set by SyncProvider — allows actions to access SDK and stores +let _sdk: OpencodeClient | null = null +let _childStores: { ensureChild: (dir: string) => StoreApi } | null = null +let _getDirectory: () => string = () => "" +let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null +let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null + +export function setActionRefs( + sdk: OpencodeClient, + childStores: { ensureChild: (dir: string) => StoreApi }, + getDirectory: () => string, +) { + _sdk = sdk + _childStores = childStores + _getDirectory = getDirectory +} + +export function setOptimisticRefs( + add: (input: { sessionID: string; message: Message; parts: Part[] }) => void, + remove: (input: { sessionID: string; messageID: string }) => void, +) { + _optimisticAdd = add + _optimisticRemove = remove +} + +function sdk() { + if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?") + return _sdk +} + +function dirStore() { + if (!_childStores) throw new Error("Child stores not initialized") + const d = _getDirectory() + if (!d) throw new Error("No current directory") + return _childStores.ensureChild(d) +} + +function dir() { + return _getDirectory() || undefined +} + +function getSessionDirectory(sessionId: string): string | undefined { + return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() +} + +function getDirectoryStore(directory?: string) { + if (!_childStores) throw new Error("Child stores not initialized") + const resolvedDirectory = directory || _getDirectory() + if (!resolvedDirectory) throw new Error("No current directory") + return _childStores.ensureChild(resolvedDirectory) +} + +function getSessionReplyClient(sessionId?: string): OpencodeClient { + const directory = sessionId + ? useSessionUIStore.getState().getDirectoryForSession(sessionId) + : null + if (directory) { + return opencodeClient.getScopedSdkClient(directory) + } + return sdk() +} + +// --------------------------------------------------------------------------- +// Session CRUD +// --------------------------------------------------------------------------- + +export async function createSession( + title?: string, + directoryOverride?: string | null, + parentID?: string | null, +): Promise { + try { + const result = await sdk().session.create({ + directory: directoryOverride ?? dir(), + title, + parentID: parentID ?? undefined, + }) + const session = result.data + if (!session) return null + + const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null + useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory) + useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) + useGlobalSessionsStore.getState().upsertSession(session) + return session + } catch (error) { + console.error("[session-actions] createSession failed", error) + return null + } +} + +/** Optimistically remove a session from the child store list. Returns previous list for rollback. */ +function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null { + const store = getDirectoryStore(directory) + const current = store.getState() + const sessions = [...current.session] + const result = Binary.search(sessions, sessionId, (s) => s.id) + if (result.found) { + const snapshot = current.session + sessions.splice(result.index, 1) + store.setState({ session: sessions }) + return snapshot + } + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function deleteSession(sessionId: string, _options?: Record): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + // Remove from UI immediately, rollback on error + const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) { + ui.setCurrentSession(null) + } + try { + await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory }) + useGlobalSessionsStore.getState().removeSessions([sessionId]) + return true + } catch (error) { + console.error("[session-actions] deleteSession failed", error) + if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) + return false + } +} + +/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */ +export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise { + if (!_childStores) return false + const store = _childStores.ensureChild(directory) + const current = store.getState() + const sessions = [...current.session] + const result = Binary.search(sessions, sessionId, (s) => s.id) + let snapshot: Session[] | null = null + if (result.found) { + snapshot = current.session + sessions.splice(result.index, 1) + store.setState({ session: sessions }) + } + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) ui.setCurrentSession(null) + try { + await sdk().session.delete({ sessionID: sessionId, directory }) + useGlobalSessionsStore.getState().removeSessions([sessionId]) + return true + } catch (error) { + console.error("[session-actions] deleteSessionInDirectory failed", error) + if (snapshot) store.setState({ session: snapshot }) + return false + } +} + +export async function archiveSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) { + ui.setCurrentSession(null) + } + try { + const archivedAt = Date.now() + await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } }) + useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt) + return true + } catch (error) { + console.error("[session-actions] archiveSession failed", error) + if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) + return false + } +} + +export async function updateSessionTitle(sessionId: string, title: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } +} + +export async function shareSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } + return result.data ?? null +} + +export async function unshareSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } + return result.data ?? null +} + +// --------------------------------------------------------------------------- +// Optimistic message send — insert user message before API call, rollback on error +// --------------------------------------------------------------------------- + +// ID generator matching OpenCode's Identifier.ascending format. +// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62. +// This ensures client-generated IDs sort correctly with server-generated ones. +let lastIdTimestamp = 0 +let idCounter = 0 + +function ascendingId(prefix: string): string { + const now = Date.now() + if (now !== lastIdTimestamp) { + lastIdTimestamp = now + idCounter = 0 + } + idCounter += 1 + + const value = BigInt(now) * BigInt(0x1000) + BigInt(idCounter) + const bytes = new Uint8Array(6) + for (let i = 0; i < 6; i++) { + bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + let hex = "" + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0") + } + + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let rand = "" + for (let i = 0; i < 14; i++) { + rand += chars[Math.floor(Math.random() * 62)] + } + + return `${prefix}_${hex}${rand}` +} + +/** + * Wraps an async send operation with optimistic user-message insertion. + * Uses useSync()'s optimistic infrastructure — message + parts are inserted + * into the store AND registered in the shadow Map. mergeOptimisticPage + * handles deduplication when the server echoes back the real message. + */ +export async function optimisticSend(input: { + sessionId: string + content: string + providerID: string + modelID: string + agent?: string + files?: Array<{ type: "file"; mime: string; url: string; filename: string }> + /** The actual API call — receives the optimistic messageID so the server can use the same ID */ + send: (messageID: string) => Promise +}): Promise { + if (!_optimisticAdd || !_optimisticRemove) { + throw new Error("Optimistic refs not set — is useSync() mounted?") + } + + const store = dirStore() + const messageID = ascendingId("msg") + const textPartId = ascendingId("prt") + + const optimisticParts: Part[] = [ + { id: textPartId, type: "text", text: input.content } as Part, + ] + if (input.files) { + for (const f of input.files) { + optimisticParts.push({ id: ascendingId("prt"), type: "file", mime: f.mime, url: f.url, filename: f.filename } as Part) + } + } + + const optimisticMessage = { + id: messageID, + role: "user" as const, + sessionID: input.sessionId, + parentID: "", + modelID: input.modelID, + providerID: input.providerID, + system: "", + agent: input.agent ?? "", + model: `${input.providerID}/${input.modelID}`, + metadata: {} as Record, + time: { created: Date.now(), completed: 0 }, + } as unknown as Message + + // Insert into store + register in shadow Map (for mergeOptimisticPage cleanup) + _optimisticAdd({ + sessionID: input.sessionId, + message: optimisticMessage, + parts: optimisticParts, + }) + + // Set busy status + const current = store.getState() + store.setState({ + session_status: { + ...current.session_status, + [input.sessionId]: { type: "busy" as const }, + }, + }) + + try { + await input.send(messageID) + } catch (error) { + // Rollback via optimistic infrastructure + _optimisticRemove({ + sessionID: input.sessionId, + messageID, + }) + const s = store.getState() + store.setState({ + session_status: { + ...s.session_status, + [input.sessionId]: { type: "idle" as const }, + }, + }) + throw error + } +} + +// --------------------------------------------------------------------------- +// Abort +// --------------------------------------------------------------------------- + +export async function abortCurrentOperation(sessionId: string): Promise { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch (error) { + console.error("[session-actions] abort failed", error) + } +} + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +export async function respondToPermission( + sessionId: string, + requestId: string, + response: "once" | "always" | "reject", +): Promise { + const result = await getSessionReplyClient(sessionId).permission.reply({ + requestID: requestId, + reply: response, + }) + if (!result.data) { + throw new Error("Permission reply failed") + } +} + +export async function dismissPermission( + sessionId: string, + requestId: string, +): Promise { + const result = await getSessionReplyClient(sessionId).permission.reply({ + requestID: requestId, + reply: "reject", + }) + if (!result.data) { + throw new Error("Permission dismissal failed") + } +} + +// --------------------------------------------------------------------------- +// Questions +// --------------------------------------------------------------------------- + +export async function respondToQuestion( + sessionId: string, + requestId: string, + answers: string[] | string[][], +): Promise { + const result = await getSessionReplyClient(sessionId).question.reply({ + requestID: requestId, + answers: answers as Array>, + }) + if (!result.data) { + throw new Error("Question reply failed") + } +} + +export async function rejectQuestion( + sessionId: string, + requestId: string, +): Promise { + const result = await getSessionReplyClient(sessionId).question.reject({ + requestID: requestId, + }) + if (!result.data) { + throw new Error("Question rejection failed") + } +} + +// --------------------------------------------------------------------------- +// Message history +// --------------------------------------------------------------------------- + +/** + * Revert to a specific user message. + * + * 1. Abort if session is busy + * 2. Extract text from the target message for prompt restoration + * 3. Optimistically set revert marker so messages hide immediately + * 4. Call SDK session.revert() and merge returned session + * 5. Set pendingInputText so the reverted message text appears in the input + */ +export async function revertToMessage(sessionId: string, messageId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Abort if busy before mutating session state + const status = state.session_status[sessionId] + if (status && status.type !== "idle") { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch { + // ignore abort errors + } + } + + // Extract message text for prompt restoration + const messages = state.message[sessionId] ?? [] + const targetMsg = messages.find((m) => m.id === messageId) + let messageText = "" + if (targetMsg && targetMsg.role === "user") { + const parts = state.part[messageId] ?? [] + const textParts = parts.filter((p) => p.type === "text") + messageText = textParts + .map((p: Record) => (p as { text?: string }).text || (p as { content?: string }).content || "") + .join("\n") + .trim() + } + + // Optimistically remove reverted messages + set marker + const prevRevert = (() => { + const s = state.session.find((s) => s.id === sessionId) + return (s as Session & { revert?: unknown })?.revert + })() + const sessions = [...state.session] + const sessionIdx = sessions.findIndex((s) => s.id === sessionId) + + // Remove messages at and after the revert point from the store + const prevMessages = state.message[sessionId] ?? [] + const prevPart = { ...state.part } + const keptMessages = prevMessages.filter((m) => m.id < messageId) + const removedMessages = prevMessages.filter((m) => m.id >= messageId) + for (const m of removedMessages) { + delete prevPart[m.id] + } + + const patch: Record = { + message: { ...state.message, [sessionId]: keptMessages }, + part: prevPart, + } + + if (sessionIdx >= 0) { + sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session + patch.session = sessions + } + + store.setState(patch) + + // Restore reverted message text to input + if (messageText) { + useInputStore.setState({ + pendingInputText: messageText, + pendingInputMode: "replace" as const, + }) + } + + // Call SDK and merge authoritative result into store + try { + const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId }) + if (result.data) { + const current = store.getState() + const updated = [...current.session] + const idx = updated.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + updated[idx] = result.data + store.setState({ session: updated }) + } + } + } catch (err) { + // Rollback: restore removed messages + revert marker + const current = store.getState() + const rollback = [...current.session] + const idx = rollback.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session + } + store.setState({ + session: rollback, + message: { ...current.message, [sessionId]: prevMessages }, + part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) }, + }) + throw err + } +} + +/** + * Unrevert — restore all previously reverted messages. + * Restore all previously reverted messages. Aborts if busy, merges result. + */ +export async function unrevertSession(sessionId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Abort if busy + const status = state.session_status[sessionId] + if (status && status.type !== "idle") { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch { + // ignore + } + } + + const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() }) + if (result.data) { + const current = store.getState() + const sessions = [...current.session] + const idx = sessions.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + sessions[idx] = result.data + store.setState({ session: sessions }) + } + } +} + +/** + * Fork from a user message. + * + * 1. Extract text from the message for input restoration + * 2. Call SDK session.fork() + * 3. Insert the new session into the child store (so sidebar updates immediately) + * 4. Switch to new session and set pending input text + */ +export async function forkFromMessage(sessionId: string, messageId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Extract message text for input restoration + const parts = state.part[messageId] ?? [] + let messageText = "" + const textParts = parts.filter((p) => p.type === "text") + messageText = textParts + .map((p: Part) => ((p as Record).text as string) || ((p as Record).content as string) || "") + .join("\n") + .trim() + + const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) + if (!result.data) return + + const forkedSession = result.data + + // Insert new session into child store so sidebar updates immediately + const current = store.getState() + const sessions = [...current.session] + const searchResult = Binary.search(sessions, forkedSession.id, (s) => s.id) + if (!searchResult.found) { + sessions.splice(searchResult.index, 0, forkedSession) + store.setState({ session: sessions }) + } + + // Switch to new session + useSessionUIStore.getState().setCurrentSession(forkedSession.id) + + // Restore forked message text to input + if (messageText) { + useInputStore.setState({ + pendingInputText: messageText, + pendingInputMode: "replace" as const, + }) + } +} diff --git a/packages/ui/src/sync/session-cache.ts b/packages/ui/src/sync/session-cache.ts new file mode 100644 index 00000000..eb0be49f --- /dev/null +++ b/packages/ui/src/sync/session-cache.ts @@ -0,0 +1,61 @@ +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part ?? {})) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has((part as { sessionID?: string })?.sessionID ?? ""))) + continue + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/packages/ui/src/sync/session-prefetch-cache.ts b/packages/ui/src/sync/session-prefetch-cache.ts new file mode 100644 index 00000000..3ec66d86 --- /dev/null +++ b/packages/ui/src/sync/session-prefetch-cache.ts @@ -0,0 +1,113 @@ +/** + * Session prefetch TTL cache — prevents redundant session fetches + * within a short window. Port of OpenCode's session-prefetch.ts. + * + * Tracks: last fetch time, pagination cursor, completeness. + * Version counter invalidates stale inflight requests after eviction. + */ + +const SESSION_PREFETCH_TTL = 15_000 + +type Meta = { + limit: number + cursor?: string + complete: boolean + at: number +} + +const compositeKey = (directory: string, sessionID: string) => + `${directory}\n${sessionID}` + +const cache = new Map() +const inflight = new Map>() +const rev = new Map() + +const version = (id: string) => rev.get(id) ?? 0 + +/** Check if a prefetch/sync can be skipped (recently fetched). */ +export function shouldSkipSessionPrefetch(input: { + hasMessages: boolean + info?: Meta + pageSize: number + now?: number +}): boolean { + if (input.hasMessages) { + if (!input.info) return true + if (input.info.complete) return true + if (input.info.limit > input.pageSize) return true + } else { + if (!input.info) return false + } + return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL +} + +export function getSessionPrefetch(directory: string, sessionID: string): Meta | undefined { + return cache.get(compositeKey(directory, sessionID)) +} + +export function getSessionPrefetchPromise(directory: string, sessionID: string) { + return inflight.get(compositeKey(directory, sessionID)) +} + +export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { + return version(compositeKey(directory, sessionID)) === value +} + +/** Run a prefetch task with inflight dedup + version tracking. */ +export function runSessionPrefetch(input: { + directory: string + sessionID: string + task: (value: number) => Promise +}) { + const id = compositeKey(input.directory, input.sessionID) + const pending = inflight.get(id) + if (pending) return pending + + const value = version(id) + + const promise = input.task(value).finally(() => { + if (inflight.get(id) === promise) inflight.delete(id) + }) + + inflight.set(id, promise) + return promise +} + +export function setSessionPrefetch(input: { + directory: string + sessionID: string + limit: number + cursor?: string + complete: boolean + at?: number +}) { + cache.set(compositeKey(input.directory, input.sessionID), { + limit: input.limit, + cursor: input.cursor, + complete: input.complete, + at: input.at ?? Date.now(), + }) +} + +/** Invalidate cache for specific sessions (e.g. after eviction). */ +export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { + for (const sessionID of sessionIDs) { + if (!sessionID) continue + const id = compositeKey(directory, sessionID) + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} + +/** Invalidate all cache entries for a directory. */ +export function clearSessionPrefetchDirectory(directory: string) { + const prefix = `${directory}\n` + const keys = new Set([...cache.keys(), ...inflight.keys()]) + for (const id of keys) { + if (!id.startsWith(prefix)) continue + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts new file mode 100644 index 00000000..0330ed68 --- /dev/null +++ b/packages/ui/src/sync/session-ui-store.ts @@ -0,0 +1,1143 @@ +/** + * Session UI Store — ephemeral UI state only. + * + * Domain data (sessions, messages, parts, permissions, questions, status) + * lives in sync child stores. This store owns ONLY transient UI concerns: + * current selection, draft state, viewport anchors, model/agent preferences, + * voice state, abort prompts, attached files, worktree metadata. + * + * SDK-calling actions that need domain data read it from sync-refs. + */ + +import { create } from "zustand" +import type { Session, Part, Message, TextPart, Agent } from "@opencode-ai/sdk/v2/client" +import type { AttachedFile, SessionContextUsage } from "@/stores/types/sessionTypes" +import type { WorktreeMetadata } from "@/types/worktree" +import { opencodeClient } from "@/lib/opencode/client" +import { useConfigStore } from "@/stores/useConfigStore" +import { useProjectsStore } from "@/stores/useProjectsStore" +import { useDirectoryStore } from "@/stores/useDirectoryStore" +import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" +import { useCommandsStore } from "@/stores/useCommandsStore" +import { getSafeStorage } from "@/stores/utils/safeStorage" +import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" +import { flattenAssistantTextParts } from "@/lib/messages/messageText" +import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta" +import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" +import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" +import type { ProjectEntry } from "@/lib/api/types" +import { + getSyncSessions, + getAllSyncSessions, + getSyncMessages, + getSyncParts, + getDirectoryState, +} from "./sync-refs" +import { markSessionViewed } from "./notification-store" +import { setActiveSession } from "./sync-context" +import { + createSession as createSessionAction, + deleteSession as deleteSessionAction, + archiveSession as archiveSessionAction, + updateSessionTitle as updateSessionTitleAction, + shareSession as shareSessionAction, + unshareSession as unshareSessionAction, + optimisticSend, +} from "./session-actions" +import { useInputStore, type SyntheticContextPart } from "./input-store" +import { useSelectionStore } from "./selection-store" +import { useViewportStore } from "./viewport-store" + +export type { AttachedFile } + +// --------------------------------------------------------------------------- +// Send routing — shell mode, slash commands, or normal prompt +// --------------------------------------------------------------------------- + +function routeMessage(params: { + sessionId: string + content: string + providerID: string + modelID: string + agent?: string + variant?: string + inputMode?: "normal" | "shell" + files?: Array<{ type: "file"; mime: string; url: string; filename: string }> + additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> +}): Promise { + const sdk = opencodeClient.getSdkClient() + + if (params.inputMode === "shell") { + const dir = opencodeClient.getDirectory() || undefined + return sdk.session.shell({ + sessionID: params.sessionId, + directory: dir, + agent: params.agent, + model: { providerID: params.providerID, modelID: params.modelID }, + command: params.content, + }).then(() => {}) + } + + // Slash commands — fire and forget, SSE delivers messages and status + if (params.content.startsWith("/")) { + const [head, ...tail] = params.content.split(" ") + const cmdName = head.slice(1) + + const dirState = getDirectoryState() + const syncCommands = dirState?.command ?? [] + const storeCommands = useCommandsStore.getState().commands + + const isCommand = syncCommands.find((c) => c.name === cmdName) + || storeCommands.find((c) => c.name === cmdName) + + if (isCommand) { + const dir = opencodeClient.getDirectory() || undefined + return sdk.session.command({ + sessionID: params.sessionId, + directory: dir, + command: cmdName, + arguments: tail.join(" "), + agent: params.agent, + model: `${params.providerID}/${params.modelID}`, + variant: params.variant, + parts: params.files, + }).then(() => {}) + } + } + + // Normal prompt — optimistic insert so message appears instantly + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, + providerID: params.providerID, + modelID: params.modelID, + agent: params.agent, + files: params.files, + send: (messageID) => opencodeClient.sendMessage({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + text: params.content, + agent: params.agent, + variant: params.variant, + files: params.files, + additionalParts: params.additionalParts, + messageId: messageID, + }).then(() => {}), + }) +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type { SyntheticContextPart } from "./input-store" +export type { SessionMemoryState } from "./viewport-store" +export type { VoiceStatus, VoiceMode } from "./voice-store" + +export type NewSessionDraftState = { + open: boolean + selectedProjectId?: string | null + directoryOverride: string | null + pendingWorktreeRequestId?: string | null + bootstrapPendingDirectory?: string | null + preserveDirectoryOverride?: boolean + parentID: string | null + title?: string + initialPrompt?: string + syntheticParts?: SyntheticContextPart[] + targetFolderId?: string +} + +export type ViewportAnchor = { + sessionId: string + value: number +} + +export type SessionHistoryMeta = { + limit: number + hasMore: boolean + complete: boolean + isLoading: boolean + loading?: boolean + nextCursor?: string +} + +export type SessionUIState = { + currentSessionId: string | null + newSessionDraft: NewSessionDraftState + abortPromptSessionId: string | null + abortPromptExpiresAt: number | null + error: string | null + worktreeMetadata: Map + availableWorktrees: WorktreeMetadata[] + availableWorktreesByProject: Map + webUICreatedSessions: Set + sessionAbortFlags: Map + abortControllers: Map + isLoading: boolean + lastLoadedDirectory: string | null + + // Actions — UI state management + setCurrentSession: (id: string | null, directoryHint?: string | null) => void + openNewSessionDraft: (options?: Partial) => void + closeNewSessionDraft: () => void + setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void + setDraftPreserveDirectoryOverride: (value: boolean) => void + acknowledgeSessionAbort: (sessionId: string) => void + clearAbortPrompt: () => void + armAbortPrompt: (durationMs?: number) => number | null + clearError: () => void + markSessionAsOpenChamberCreated: (sessionId: string) => void + isOpenChamberCreatedSession: (sessionId: string) => boolean + getContextUsage: (contextLimit: number, outputLimit: number) => SessionContextUsage | null + initializeNewOpenChamberSession: (sessionId: string, agents: unknown[]) => void + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void + overrideNewSessionDraftTarget: (options: Record) => void + resolvePendingDraftWorktreeTarget: (requestId: string, directory: string | null, options?: Record) => void + setDraftBootstrapPendingDirectory: (directory: string | null) => void + setPendingDraftWorktreeRequest: (requestId: string | null) => void + getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined + + // Actions — SDK-calling operations (read domain data from sync-refs) + sendMessage: ( + content: string, + providerID: string, + modelID: string, + agent?: string, + attachments?: AttachedFile[], + agentMentionName?: string, + additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, + variant?: string, + inputMode?: "normal" | "shell", + ) => Promise + + createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise + deleteSession: (id: string, options?: Record) => Promise + deleteSessions: (ids: string[], options?: Record) => Promise<{ deletedIds: string[]; failedIds: string[] }> + archiveSession: (id: string) => Promise + archiveSessions: (ids: string[], options?: Record) => Promise<{ archivedIds: string[]; failedIds: string[] }> + updateSessionTitle: (sessionId: string, title: string) => Promise + shareSession: (sessionId: string) => Promise + unshareSession: (sessionId: string) => Promise + revertToMessage: (sessionId: string, messageId: string) => Promise + forkFromMessage: (sessionId: string, messageId: string) => Promise + handleSlashUndo: (sessionId: string) => Promise + handleSlashRedo: (sessionId: string) => Promise + createSessionFromAssistantMessage: (sourceMessageId: string) => Promise + + // Data access helpers (read from sync) + getSessionsByDirectory: (directory: string) => Session[] + getDirectoryForSession: (sessionId: string) => string | null + getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null + getCurrentAgent: (sessionId: string) => string | undefined + debugSessionMessages: (sessionId: string) => Promise + pollForTokenUpdates: () => void + setSessionDirectory: (sessionId: string, directory: string | null) => void +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const normalizePath = (value?: string | null): string | null => { + if (typeof value !== "string") return null + const trimmed = value.trim() + if (!trimmed) return null + const replaced = trimmed.replace(/\\/g, "/") + if (replaced === "/") return "/" + return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced +} + +const resolveDirectoryKey = (session: Session): string | null => { + const sessionRecord = session as Session & { + directory?: string | null + project?: { worktree?: string | null } | null + } + return normalizePath(sessionRecord.directory ?? null) + ?? normalizePath(sessionRecord.project?.worktree ?? null) +} + +const safeStorage = getSafeStorage() +const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" + +type PersistedDraftTarget = { projectId: string | null; directory: string | null } + +const readPersistedDraftTarget = (): PersistedDraftTarget | null => { + try { + const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown } + return { + projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null, + directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null), + } + } catch { + return null + } +} + +const persistDraftTarget = (target: PersistedDraftTarget): void => { + try { + safeStorage.setItem(DRAFT_TARGET_STORAGE_KEY, JSON.stringify(target)) + } catch { /* ignored */ } +} + +const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => { + const nd = normalizePath(directory) + if (!nd) return null + let best: ProjectEntry | null = null + for (const p of projects) { + const pp = normalizePath(p.path) + if (!pp) continue + if (nd !== pp && !nd.startsWith(`${pp}/`)) continue + if (!best || pp.length > (normalizePath(best.path)?.length ?? 0)) best = p + } + return best +} + +const resolveProjectFromWorktreeDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => { + const nd = normalizePath(directory) + if (!nd) return null + let matchedWorktree: WorktreeMetadata | null = null + let matchedProjectPath: string | null = null + let bestLen = -1 + for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) { + for (const wt of worktrees) { + const wp = normalizePath(wt.path) + if (!wp) continue + if (nd !== wp && !nd.startsWith(`${wp}/`)) continue + if (wp.length > bestLen) { + bestLen = wp.length + matchedWorktree = wt + matchedProjectPath = normalizePath(projectPath) + } + } + } + if (!matchedWorktree) return null + const candidates = [normalizePath(matchedWorktree.projectDirectory), matchedProjectPath].filter((v): v is string => Boolean(v)) + for (const c of candidates) { + const exact = projects.find((p) => normalizePath(p.path) === c) ?? null + if (exact) return exact + const nested = resolveProjectForDirectory(projects, c) + if (nested) return nested + } + return null +} + +const resolveDraftProjectForDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => + resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ?? + resolveProjectForDirectory(projects, directory) + +const resolveSessionDirectory = ( + sessionId: string | null | undefined, + getWtMeta: (id: string) => WorktreeMetadata | undefined, +): string | null => { + if (!sessionId) return null + const metaPath = getWtMeta(sessionId)?.path + if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath) + const sessions = getAllSyncSessions() + const target = sessions.find((s) => s.id === sessionId) + if (!target) return null + return resolveDirectoryKey(target) +} + +const DEFAULT_DRAFT: NewSessionDraftState = { + open: false, + directoryOverride: null, + parentID: null, +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export const useSessionUIStore = create()((set, get) => ({ + currentSessionId: null, + newSessionDraft: { ...DEFAULT_DRAFT }, + abortPromptSessionId: null, + abortPromptExpiresAt: null, + error: null, + worktreeMetadata: new Map(), + availableWorktrees: [], + availableWorktreesByProject: new Map(), + webUICreatedSessions: new Set(), + sessionAbortFlags: new Map(), + abortControllers: new Map(), + isLoading: false, + lastLoadedDirectory: null, + + // --------------------------------------------------------------------------- + // setCurrentSession + // --------------------------------------------------------------------------- + setCurrentSession: (id, directoryHint?: string | null) => { + if (id) { + get().closeNewSessionDraft() + } + + const previousSessionId = get().currentSessionId + const directoryState = useDirectoryStore.getState() + + const sessionDir = resolveSessionDirectory( + id, + (sid) => get().worktreeMetadata.get(sid), + ) + const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null + const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir + + try { + if (resolvedDir && directoryState.currentDirectory !== resolvedDir) { + directoryState.setDirectory(resolvedDir, { showOverlay: false }) + } + opencodeClient.setDirectory(resolvedDir ?? undefined) + } catch (e) { + console.warn("Failed to set OpenCode directory for session switch:", e) + } + + // Save viewport anchor for previous session + if (previousSessionId && previousSessionId !== id) { + const memState = useViewportStore.getState().sessionMemoryState.get(previousSessionId) + if (!memState?.isStreaming) { + const prevMessages = getSyncMessages(previousSessionId) + if (prevMessages.length > 0) { + useViewportStore.getState().updateViewportAnchor(previousSessionId, prevMessages.length - 1) + } + } + } + + set({ currentSessionId: id }) + + // Mark session viewed in notification store + update active session ref + // Mark session viewed in notification store + update active session ref + if (id) { + markSessionViewed(id) + setActiveSession(resolvedDir ?? "", id) + } + }, + + // --------------------------------------------------------------------------- + // openNewSessionDraft + // --------------------------------------------------------------------------- + openNewSessionDraft: (options) => { + const projectsState = useProjectsStore.getState() + const projects = projectsState.projects + const availableWorktreesByProject = get().availableWorktreesByProject + const activeProject = projectsState.getActiveProject() + const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null) + const persistedTarget = readPersistedDraftTarget() + + const explicitDirectory = options?.directoryOverride !== undefined + ? normalizePath(options.directoryOverride) + : null + const explicitProject = options?.selectedProjectId + ? projects.find((p) => p.id === options.selectedProjectId) ?? null + : null + + const inferredProjectFromDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory) + const fallbackProject = (() => { + if (activeProject) return activeProject + if (projectsState.activeProjectId) return projects.find((p) => p.id === projectsState.activeProjectId) ?? null + return projects[0] ?? null + })() + + const persistedProjectById = persistedTarget?.projectId + ? projects.find((p) => p.id === persistedTarget.projectId) ?? null + : null + const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) + const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) + + const selectedProject = (() => { + if (explicitProject || explicitDirectory !== null) { + return explicitProject ?? inferredProjectFromDir ?? fallbackProject + } + if (currentDirectory) return currentDirProject ?? fallbackProject + return persistedProjectByDir ?? persistedProjectById ?? fallbackProject + })() + + const directory = (() => { + if (explicitDirectory !== null) return explicitDirectory + if (explicitProject) return normalizePath(explicitProject.path ?? null) + if (currentDirectory) return currentDirectory + if (persistedTarget?.directory) return persistedTarget.directory + return normalizePath(selectedProject?.path ?? null) + })() + + persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) + + set({ + newSessionDraft: { + open: true, + selectedProjectId: selectedProject?.id ?? null, + directoryOverride: directory, + pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, + bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), + preserveDirectoryOverride: options?.preserveDirectoryOverride === true, + parentID: options?.parentID ?? null, + title: options?.title, + initialPrompt: options?.initialPrompt, + syntheticParts: options?.syntheticParts, + targetFolderId: options?.targetFolderId, + }, + currentSessionId: null, + error: null, + }) + + if (options?.initialPrompt) { + useInputStore.getState().setPendingInputText(options.initialPrompt) + } + + try { + const configState = useConfigStore.getState() + const visibleAgents = configState.getVisibleAgents() + let agentName: string | undefined + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a: Agent) => a.name === configState.settingsDefaultAgent) + if (settingsAgent) agentName = settingsAgent.name + } + if (!agentName) { + agentName = visibleAgents.find((a: Agent) => a.name === "build")?.name || visibleAgents[0]?.name + } + if (agentName) configState.setAgent(agentName) + } catch { /* ignored */ } + }, + + // --------------------------------------------------------------------------- + // closeNewSessionDraft + // --------------------------------------------------------------------------- + closeNewSessionDraft: () => { + set({ + newSessionDraft: { + open: false, + selectedProjectId: null, + directoryOverride: null, + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: null, + preserveDirectoryOverride: false, + parentID: null, + title: undefined, + initialPrompt: undefined, + syntheticParts: undefined, + targetFolderId: undefined, + }, + }) + }, + + setNewSessionDraftTarget: (target) => + set((s) => ({ + newSessionDraft: { + ...s.newSessionDraft, + selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId, + directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride, + }, + })), + + setDraftPreserveDirectoryOverride: (value) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, preserveDirectoryOverride: value } } + }), + + acknowledgeSessionAbort: (sessionId) => + set((s) => { + const flags = new Map(s.sessionAbortFlags) + const existing = flags.get(sessionId) + if (existing) flags.set(sessionId, { ...existing, acknowledged: true }) + return { sessionAbortFlags: flags } + }), + + clearAbortPrompt: () => set({ abortPromptSessionId: null, abortPromptExpiresAt: null }), + + armAbortPrompt: (durationMs = 5000) => { + const { currentSessionId } = get() + if (!currentSessionId) return null + const expiresAt = Date.now() + durationMs + set({ abortPromptSessionId: currentSessionId, abortPromptExpiresAt: expiresAt }) + return expiresAt + }, + + clearError: () => set({ error: null }), + + markSessionAsOpenChamberCreated: (sessionId) => + set((s) => { + const next = new Set(s.webUICreatedSessions) + next.add(sessionId) + return { webUICreatedSessions: next } + }), + + isOpenChamberCreatedSession: (sessionId) => get().webUICreatedSessions.has(sessionId), + + getContextUsage: (contextLimit: number, outputLimit: number) => { + if (get().newSessionDraft?.open) return null + const sessionId = get().currentSessionId + if (!sessionId) return null + + const messages = getSyncMessages(sessionId) + if (messages.length === 0) return null + + // Find last assistant message with token data + type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + let lastTokens: AssistantTokens | undefined + let lastMessageId: string | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + const tokens = (msg as { tokens?: AssistantTokens }).tokens + if (!tokens) continue + const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) + if (total > 0) { + lastTokens = tokens + lastMessageId = msg.id + break + } + } + + if (!lastTokens) return null + + const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0) + const thresholdLimit = contextLimit > 0 ? contextLimit : 200000 + const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0 + const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined + + return { + totalTokens, + percentage, + contextLimit: contextLimit || 0, + outputLimit: outputLimit || undefined, + normalizedOutput, + thresholdLimit, + lastMessageId, + } + }, + + initializeNewOpenChamberSession: () => { + // Stub — was a no-op in old store + }, + + setWorktreeMetadata: (sessionId, metadata) => + set((s) => { + const map = new Map(s.worktreeMetadata) + if (metadata) map.set(sessionId, metadata) + else map.delete(sessionId) + return { worktreeMetadata: map } + }), + + overrideNewSessionDraftTarget: (options) => + set((s) => ({ + newSessionDraft: { ...s.newSessionDraft, ...options }, + })), + + resolvePendingDraftWorktreeTarget: (requestId, directory, options) => + set((s) => { + if (!s.newSessionDraft?.open || s.newSessionDraft.pendingWorktreeRequestId !== requestId) return s + return { + newSessionDraft: { + ...s.newSessionDraft, + selectedProjectId: (options as Record | undefined)?.projectId as string ?? s.newSessionDraft.selectedProjectId ?? null, + directoryOverride: normalizePath(directory), + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: normalizePath((options as Record | undefined)?.bootstrapPendingDirectory as string ?? s.newSessionDraft.bootstrapPendingDirectory ?? null), + preserveDirectoryOverride: ((options as Record | undefined)?.preserveDirectoryOverride ?? true) as boolean, + }, + } + }), + + setDraftBootstrapPendingDirectory: (directory) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, bootstrapPendingDirectory: normalizePath(directory) } } + }), + + setPendingDraftWorktreeRequest: (requestId) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, pendingWorktreeRequestId: requestId } } + }), + + getWorktreeMetadata: (sessionId) => get().worktreeMetadata.get(sessionId), + + // --------------------------------------------------------------------------- + // sendMessage — calls SDK, reads domain data from sync + // --------------------------------------------------------------------------- + sendMessage: async ( + content: string, + providerID: string, + modelID: string, + agent?: string, + attachments?: AttachedFile[], + agentMentionName?: string, + additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, + variant?: string, + inputMode?: "normal" | "shell", + ) => { + const draft = get().newSessionDraft + const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined + + // ---- New session from draft ---- + if (draft?.open) { + const draftTargetFolderId = draft.targetFolderId + let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null + const draftProjectId = draft.selectedProjectId ?? null + + if (draft.pendingWorktreeRequestId) { + draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId) + get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) + } + + const created = await get().createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) + if (!created?.id) throw new Error("Failed to create session") + + persistDraftTarget({ + projectId: draftProjectId, + directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), + }) + + const configState = useConfigStore.getState() + const draftAgentName = configState.currentAgentName + const effectiveDraftAgent = trimmedAgent ?? draftAgentName + + if (configState.currentProviderId && configState.currentModelId) { + useSelectionStore.getState().saveSessionModelSelection(created.id, configState.currentProviderId, configState.currentModelId) + } + + if (effectiveDraftAgent) { + useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent) + if (configState.currentProviderId && configState.currentModelId) { + useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, configState.currentProviderId, configState.currentModelId) + useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, configState.currentProviderId, configState.currentModelId, variant) + } + } + + get().initializeNewOpenChamberSession(created.id, configState.agents ?? []) + + const draftSyntheticParts = draft.syntheticParts + const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) + + get().closeNewSessionDraft() + get().setCurrentSession(created.id, createdDirectory) + + if (draftTargetFolderId) { + const scopeKey = draftDirectoryOverride || created.directory || null + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id) + } + } + + const mergedAdditionalParts = draftSyntheticParts?.length + ? [...(additionalParts || []), ...draftSyntheticParts] + : additionalParts + + if (createdDirectory) { + await waitForWorktreeBootstrap(createdDirectory) + } + + markPendingUserSendAnimation(created.id) + + const files = attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })) + + await routeMessage({ + sessionId: created.id, + content, + providerID, + modelID, + agent: effectiveDraftAgent, + variant, + inputMode, + files, + additionalParts: mergedAdditionalParts?.map((p) => ({ + text: p.text, + synthetic: p.synthetic, + files: p.attachments?.map((a: AttachedFile) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })), + })), + }) + return + } + + // ---- Existing session ---- + const currentSessionId = get().currentSessionId + const sessionAgentSelection = currentSessionId + ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) + : null + const configAgentName = useConfigStore.getState().currentAgentName + const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined + + if (currentSessionId && effectiveAgent) { + useSelectionStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent) + useSelectionStore.getState().saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant) + } + + if (currentSessionId) { + const viewportState = useViewportStore.getState() + const memState = viewportState.sessionMemoryState.get(currentSessionId) + if (!memState || !memState.lastUserMessageAt) { + const newMemState = new Map(viewportState.sessionMemoryState) + newMemState.set(currentSessionId, { + viewportAnchor: memState?.viewportAnchor ?? 0, + isStreaming: memState?.isStreaming ?? false, + lastAccessedAt: Date.now(), + backgroundMessageCount: memState?.backgroundMessageCount ?? 0, + lastUserMessageAt: Date.now(), + }) + useViewportStore.setState({ sessionMemoryState: newMemState }) + } + } + + const currentSessionDirectory = currentSessionId + ? normalizePath(get().getDirectoryForSession(currentSessionId)) + : null + if (currentSessionDirectory) { + await waitForWorktreeBootstrap(currentSessionDirectory) + } + + if (currentSessionId) { + fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: "POST" }) + .catch(() => { /* ignore */ }) + } + + if (currentSessionId) { + markPendingUserSendAnimation(currentSessionId) + } + + const files = attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })) + + await routeMessage({ + sessionId: currentSessionId || "", + content, + providerID, + modelID, + agent: effectiveAgent, + variant, + inputMode, + files, + additionalParts: additionalParts?.map((p) => ({ + text: p.text, + synthetic: p.synthetic, + files: p.attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })), + })), + }) + }, + + // --------------------------------------------------------------------------- + // createSession + // --------------------------------------------------------------------------- + createSession: async (title, directoryOverride, parentID) => { + const draft = get().newSessionDraft + const targetFolderId = draft.targetFolderId + get().closeNewSessionDraft() + + try { + const dir = directoryOverride ?? opencodeClient.getDirectory() + const session = await createSessionAction(title, dir, parentID ?? null) + if (!session) return null + + if (targetFolderId) { + const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id) + } + } + + return session + } catch (e) { + console.error("[session-ui-store] createSession failed", e) + return null + } + }, + + // --------------------------------------------------------------------------- + // deleteSession — calls SDK, SSE event updates child store + // --------------------------------------------------------------------------- + deleteSession: (id) => deleteSessionAction(id), + + deleteSessions: async (ids) => { + const deletedIds: string[] = [] + const failedIds: string[] = [] + for (const id of ids) { + const ok = await deleteSessionAction(id) + if (ok) deletedIds.push(id) + else failedIds.push(id) + } + return { deletedIds, failedIds } + }, + + archiveSession: (id) => archiveSessionAction(id), + + archiveSessions: async (ids) => { + const archivedIds: string[] = [] + const failedIds: string[] = [] + for (const id of ids) { + const ok = await archiveSessionAction(id) + if (ok) archivedIds.push(id) + else failedIds.push(id) + } + return { archivedIds, failedIds } + }, + + // --------------------------------------------------------------------------- + // updateSessionTitle — calls SDK, SSE event updates child store + // --------------------------------------------------------------------------- + updateSessionTitle: async (sessionId, title) => { + await updateSessionTitleAction(sessionId, title) + }, + + shareSession: async (sessionId) => { + return shareSessionAction(sessionId) + }, + + unshareSession: async (sessionId) => { + return unshareSessionAction(sessionId) + }, + + // --------------------------------------------------------------------------- + // revertToMessage — delegates to session-actions (single implementation) + // --------------------------------------------------------------------------- + revertToMessage: async (sessionId, messageId) => { + const { revertToMessage: revert } = await import("./session-actions") + await revert(sessionId, messageId) + }, + + // --------------------------------------------------------------------------- + // handleSlashUndo — reads from sync + // --------------------------------------------------------------------------- + handleSlashUndo: async (sessionId) => { + const messages = getSyncMessages(sessionId) + const sessions = getSyncSessions() + const currentSession = sessions.find((s) => s.id === sessionId) + + const userMessages = messages.filter((m) => m.role === "user") + if (userMessages.length === 0) return + + const revertToId = currentSession?.revert?.messageID + let targetMessage: typeof messages[number] | undefined + if (revertToId) { + const revertIndex = userMessages.findIndex((m) => m.id === revertToId) + targetMessage = userMessages[revertIndex + 1] + } else { + targetMessage = userMessages[userMessages.length - 1] + } + + if (!targetMessage) return + + const targetParts = getSyncParts(targetMessage.id) + const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined + const preview = textPart?.text + ? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "") + : "[No text]" + + await get().revertToMessage(sessionId, targetMessage.id) + + const { toast } = await import("sonner") + toast.success(`Undid to: ${preview}`) + }, + + // --------------------------------------------------------------------------- + // handleSlashRedo — reads from sync + // --------------------------------------------------------------------------- + handleSlashRedo: async (sessionId) => { + const sessions = getSyncSessions() + const currentSession = sessions.find((s) => s.id === sessionId) + const revertToId = currentSession?.revert?.messageID + if (!revertToId) return + + const messages = getSyncMessages(sessionId) + const userMessages = messages.filter((m) => m.role === "user") + const revertIndex = userMessages.findIndex((m) => m.id === revertToId) + const targetMessage = userMessages[revertIndex - 1] + + if (targetMessage) { + const targetParts = getSyncParts(targetMessage.id) + const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined + const preview = textPart?.text + ? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "") + : "[No text]" + + await get().revertToMessage(sessionId, targetMessage.id) + + const { toast } = await import("sonner") + toast.success(`Redid to: ${preview}`) + } else { + // Full unrevert + const { unrevertSession } = await import("./session-actions") + await unrevertSession(sessionId) + + const { toast } = await import("sonner") + toast.success("Restored all messages") + } + }, + + // --------------------------------------------------------------------------- + // forkFromMessage — delegates to session-actions (handles text + sidebar) + // --------------------------------------------------------------------------- + forkFromMessage: async (sessionId, messageId) => { + const sessions = getSyncSessions() + const existingSession = sessions.find((s) => s.id === sessionId) + if (!existingSession) return + + try { + const { forkFromMessage: fork } = await import("./session-actions") + await fork(sessionId, messageId) + + const { toast } = await import("sonner") + toast.success(`Forked from ${existingSession.title}`) + } catch (error) { + console.error("Failed to fork session:", error) + const { toast } = await import("sonner") + toast.error("Failed to fork session") + } + }, + + // --------------------------------------------------------------------------- + // createSessionFromAssistantMessage — reads from sync + // --------------------------------------------------------------------------- + createSessionFromAssistantMessage: async (sourceMessageId) => { + if (!sourceMessageId) return + + // Find which session this message belongs to by scanning sync state + const state = getDirectoryState() + if (!state) return + + let sourceSessionId: string | undefined + let sourceMessage: Message | undefined + + for (const [sid, msgs] of Object.entries(state.message ?? {})) { + const found = msgs.find((m) => m.id === sourceMessageId) + if (found) { + sourceSessionId = sid + sourceMessage = found + break + } + } + + if (!sourceMessage || sourceMessage.role !== "assistant") return + + const sourceParts = getSyncParts(sourceMessageId) + const assistantPlanText = flattenAssistantTextParts(sourceParts) + if (!assistantPlanText.trim()) return + + const directory = resolveSessionDirectory( + sourceSessionId ?? null, + (sid) => get().worktreeMetadata.get(sid), + ) + + const session = await get().createSession(undefined, directory ?? null, null) + if (!session) return + + const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState() + const pID = currentProviderId || useSelectionStore.getState().lastUsedProvider?.providerID + const mID = currentModelId || useSelectionStore.getState().lastUsedProvider?.modelID + + if (!pID || !mID) return + + await opencodeClient.sendMessage({ + id: session.id, + providerID: pID, + modelID: mID, + text: assistantPlanText, + prefaceText: EXECUTION_FORK_META_TEXT, + agent: currentAgentName ?? undefined, + }) + }, + + // --------------------------------------------------------------------------- + // Data access helpers — read from sync + // --------------------------------------------------------------------------- + getSessionsByDirectory: (directory) => { + const nd = normalizePath(directory) + if (!nd) return [] + const sessions = getAllSyncSessions() + return sessions.filter((s) => resolveDirectoryKey(s) === nd) + }, + + getDirectoryForSession: (sessionId) => { + const sessions = getAllSyncSessions() + const session = sessions.find((s) => s.id === sessionId) + if (!session) return null + return resolveDirectoryKey(session) + }, + + getLastUserChoice: (sessionId) => { + const directory = get().getDirectoryForSession(sessionId) ?? undefined + const messages = getSyncMessages(sessionId, directory) + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as Message & { + model?: { providerID?: string; modelID?: string } + variant?: string + mode?: string + } + if (message.role !== "user") { + continue + } + + const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined + const modelID = typeof message.model?.modelID === "string" && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined + const agent = typeof message.agent === "string" && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined) + const variant = typeof message.variant === "string" && message.variant.trim().length > 0 + ? message.variant + : undefined + + return { agent, providerID, modelID, variant } + } + return null + }, + + getCurrentAgent: (sessionId) => { + return useSelectionStore.getState().sessionAgentSelections.get(sessionId) ?? undefined + }, + + debugSessionMessages: async (sessionId) => { + const msgs = getSyncMessages(sessionId) + const sessions = getSyncSessions() + const session = sessions.find((s) => s.id === sessionId) + console.log(`Debug session ${sessionId}:`, { + session, + messageCount: msgs.length, + messages: msgs.map((m) => ({ + id: m.id, + role: m.role, + tokens: m.role === "assistant" ? m.tokens : undefined, + })), + }) + }, + + pollForTokenUpdates: () => { + // Handled by sync system's SSE stream + }, + + setSessionDirectory: () => { + // Session directory is owned by sync child stores via SSE events. + // This is now a no-op — kept for interface compatibility during migration. + }, +})) diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts new file mode 100644 index 00000000..d178a1dc --- /dev/null +++ b/packages/ui/src/sync/streaming.ts @@ -0,0 +1,131 @@ +/** + * Streaming lifecycle tracking. + * + * Derives streaming state from the sync child store's session_status and + * message/part updates. Components read this to know which messages are + * currently streaming and their lifecycle phase. + */ + +import { create } from "zustand" +import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { State } from "./types" + +export type StreamPhase = "streaming" | "cooldown" | "completed" + +export type MessageStreamState = { + phase: StreamPhase + startedAt: number + lastUpdateAt: number + completedAt?: number +} + +export type StreamingStore = { + /** Currently streaming message per session */ + streamingMessageIds: Map + /** Lifecycle phase per message */ + messageStreamStates: Map +} + +export const useStreamingStore = create()(() => ({ + streamingMessageIds: new Map(), + messageStreamStates: new Map(), +})) + +/** + * Called from the SyncBridge/flush handler when child store state changes. + * Derives streaming state from session_status + messages. + */ +export function updateStreamingState(state: State) { + const now = Date.now() + const nextStreamingIds = new Map() + const nextStreamStates = new Map(useStreamingStore.getState().messageStreamStates) + let changed = false + + for (const [sessionID, status] of Object.entries(state.session_status ?? {})) { + const isBusy = (status as SessionStatus).type === "busy" + const messages = state.message[sessionID] + + if (isBusy && messages && messages.length > 0) { + // Find the last assistant message — that's the one streaming + let streamingMsg: Message | null = null + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + streamingMsg = messages[i] + break + } + } + + if (streamingMsg) { + const prevId = nextStreamingIds.get(sessionID) + if (prevId !== streamingMsg.id) changed = true + nextStreamingIds.set(sessionID, streamingMsg.id) + + const existing = nextStreamStates.get(streamingMsg.id) + if (!existing || existing.phase !== "streaming") { + nextStreamStates.set(streamingMsg.id, { + phase: "streaming", + startedAt: existing?.startedAt ?? now, + lastUpdateAt: now, + }) + changed = true + } else if (existing.lastUpdateAt !== now) { + nextStreamStates.set(streamingMsg.id, { + ...existing, + lastUpdateAt: now, + }) + changed = true + } + } + } else { + // Session is idle — check if we had a streaming message + const prev = useStreamingStore.getState().streamingMessageIds.get(sessionID) + if (prev) { + nextStreamingIds.set(sessionID, null) + const existing = nextStreamStates.get(prev) + if (existing && existing.phase === "streaming") { + // Transition to cooldown then completed + nextStreamStates.set(prev, { + ...existing, + phase: "completed", + completedAt: now, + }) + changed = true + } + } + } + } + + // Also mark completed any streaming messages for sessions no longer in status + const currentIds = useStreamingStore.getState().streamingMessageIds + for (const [sessionID, msgId] of currentIds) { + if (msgId && !state.session_status?.[sessionID]) { + const existing = nextStreamStates.get(msgId) + if (existing && existing.phase === "streaming") { + nextStreamStates.set(msgId, { + ...existing, + phase: "completed", + completedAt: now, + }) + changed = true + } + nextStreamingIds.set(sessionID, null) + } + } + + if (changed) { + useStreamingStore.setState({ + streamingMessageIds: nextStreamingIds, + messageStreamStates: nextStreamStates, + }) + } +} + +// Selectors +export const selectStreamingMessageId = (sessionID: string) => + (state: StreamingStore) => state.streamingMessageIds.get(sessionID) ?? null + +export const selectMessageStreamState = (messageID: string) => + (state: StreamingStore) => state.messageStreamStates.get(messageID) ?? null + +export const selectIsStreaming = (sessionID: string) => + (state: StreamingStore) => state.streamingMessageIds.get(sessionID) != null diff --git a/packages/ui/src/sync/submit.ts b/packages/ui/src/sync/submit.ts new file mode 100644 index 00000000..943f9e66 --- /dev/null +++ b/packages/ui/src/sync/submit.ts @@ -0,0 +1,143 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { useCallback } from "react" +import { useSyncSDK } from "./sync-context" +import { useDirectoryStore } from "./sync-context" +import { useSync } from "./use-sync" + +// --------------------------------------------------------------------------- +// Ascending ID generator — monotonic timestamp + sequence counter +// --------------------------------------------------------------------------- + +let counter = 0 + +function ascending(prefix: string): string { + const now = Date.now() + const seq = (counter++ % 1000).toString().padStart(3, "0") + return `${prefix}_${now}${seq}` +} + +// --------------------------------------------------------------------------- +// Prompt submission with optimistic updates +// Prompt submission with optimistic message insertion +// --------------------------------------------------------------------------- + +export type SubmitInput = { + sessionID: string + text: string + parts?: Part[] + agent: string + model: { providerID: string; modelID: string } + variant?: string + command?: { name: string; arguments: string } + images?: Array<{ id?: string; type: "file"; mime: string; url: string; filename: string }> +} + +export function usePromptSubmit() { + const sdk = useSyncSDK() + const store = useDirectoryStore() + const sync = useSync() + + const submit = useCallback( + async (input: SubmitInput) => { + const messageID = ascending("message") + + // Build optimistic user message + const message: Message = { + id: messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: input.model, + variant: input.variant, + } as Message + + // Build optimistic parts + const textPart: Part = { + id: ascending("part"), + sessionID: input.sessionID, + messageID, + type: "text", + text: input.text, + } as Part + + const optimisticParts: Part[] = [textPart, ...(input.parts ?? [])] + + // Set busy status optimistically + store.setState((prev) => ({ + ...prev, + session_status: { + ...prev.session_status, + [input.sessionID]: { type: "busy" }, + }, + })) + + // Add optimistic message immediately + sync.optimistic.add({ + sessionID: input.sessionID, + message, + parts: optimisticParts, + }) + + try { + if (input.command) { + // Slash command + await sdk.session.command({ + sessionID: input.sessionID, + command: input.command.name, + arguments: input.command.arguments, + agent: input.agent, + model: `${input.model.providerID}/${input.model.modelID}`, + variant: input.variant, + parts: input.images, + }) + } else { + // Regular prompt + const requestParts: Array<{ id: string; type: "text"; text: string } + | { id: string; type: "file"; mime: string; url: string; filename?: string }> = [ + { id: textPart.id, type: "text" as const, text: input.text }, + ] + if (input.images) { + for (const img of input.images) { + requestParts.push({ + id: img.id ?? ascending("part"), + type: "file" as const, + mime: img.mime, + url: img.url, + filename: img.filename, + }) + } + } + + await sdk.session.promptAsync({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID, + parts: requestParts, + variant: input.variant, + }) + } + return true + } catch (error) { + // Revert optimistic on failure + sync.optimistic.remove({ + sessionID: input.sessionID, + messageID, + }) + // Reset status + store.setState((prev) => ({ + ...prev, + session_status: { + ...prev.session_status, + [input.sessionID]: { type: "idle" }, + }, + })) + throw error + } + }, + [sdk, store, sync], + ) + + return submit +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx new file mode 100644 index 00000000..afeb34c8 --- /dev/null +++ b/packages/ui/src/sync/sync-context.tsx @@ -0,0 +1,639 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react" +import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client" +import type { StoreApi } from "zustand" +import { useStore } from "zustand" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { createEventPipeline } from "./event-pipeline" +import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" +import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" +import { ChildStoreManager, type DirectoryStore } from "./child-store" +import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" +import { retry } from "./retry" +import { updateStreamingState } from "./streaming" +import { setActionRefs } from "./session-actions" +import { setSyncRefs } from "./sync-refs" +import { opencodeClient } from "@/lib/opencode/client" +import { usePermissionStore } from "@/stores/permissionStore" +import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept" +import { appendNotification } from "./notification-store" +import type { State } from "./types" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@/types/permission" +import type { QuestionRequest } from "@/types/question" +import { create } from "zustand" +import * as sessionActions from "./session-actions" + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +type SyncSystem = { + childStores: ChildStoreManager + sdk: OpencodeClient + directory: string +} + +const SyncContext = createContext(null) + +function useSyncSystem() { + const ctx = useContext(SyncContext) + if (!ctx) throw new Error("useSyncSystem must be used within ") + return ctx +} + +// --------------------------------------------------------------------------- +// Event handler — applies one SSE event at a time to the live store. +// Each event reads live state, creates a shallow draft, applies, writes back. +// React 18 batches synchronous setState calls automatically. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Global session status store — cross-directory status tracking. +// +// OpenCode isolates sessions behind project navrails, so per-directory +// session_status is sufficient. OpenChamber shows all sessions in one sidebar, +// so we need a global view. Updated from handleEvent on every session.status. +// --------------------------------------------------------------------------- + +interface GlobalSessionStatusStore { + statuses: Record +} + +const useGlobalSessionStatusStore = create(() => ({ + statuses: {}, +})) + +function setGlobalSessionStatus(sessionId: string, status: SessionStatus) { + const current = useGlobalSessionStatusStore.getState().statuses + if (current[sessionId] === status) return + useGlobalSessionStatusStore.setState({ + statuses: { ...current, [sessionId]: status }, + }) +} + +/** Read status for a session across all directories */ +export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { + return useGlobalSessionStatusStore((s) => s.statuses[sessionId]) +} + +/** Read all session statuses (for sidebar) */ +export function useAllSessionStatuses(): Record { + return useGlobalSessionStatusStore((s) => s.statuses) +} + +// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup. +let bootingRoot = false +let bootedAt = 0 +const BOOT_DEBOUNCE_MS = 1500 + +// Module-level refs for notification viewed check. +// Used to determine if user is currently viewing the session when a notification arrives. +let _activeDirectory = "" +let _activeSession = "" + +export function setActiveSession(directory: string, sessionId: string) { + _activeDirectory = directory + _activeSession = sessionId +} + +function isViewedInCurrentSession(directory: string, sessionId?: string): boolean { + if (!_activeDirectory || !_activeSession || !sessionId) return false + if (directory !== _activeDirectory) return false + return sessionId === _activeSession +} + +function isRecentBoot() { + return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS +} + +function handleEvent( + directory: string, + payload: Event, + childStores: ChildStoreManager, +) { + // Global events + if (directory === "global" || !directory) { + const recent = isRecentBoot() + const result = reduceGlobalEvent(payload) + if (!result) return + if (result.type === "refresh") { + // Suppress refresh during/shortly after bootstrap + if (!recent) { + useGlobalSyncStore.setState({ reload: "pending" }) + } + } else if (result.type === "project") { + const current = useGlobalSyncStore.getState() + useGlobalSyncStore.setState({ + projects: applyGlobalProject(current, result.project).projects, + }) + } + // On server.connected / global.disposed, re-bootstrap all directories + // but only if not during recent boot + if (payload.type === "server.connected" || payload.type === "global.disposed") { + if (!recent) { + for (const dir of childStores.children.keys()) { + const store = childStores.getChild(dir) + if (store && store.getState().status !== "loading") { + // Mark as loading to trigger re-bootstrap + store.setState({ status: "loading" as const }) + childStores.ensureChild(dir) + } + } + } + } + return + } + + // Directory events + const store = childStores.getChild(directory) + if (!store) { + // Try as global event for unknown directories + const result = reduceGlobalEvent(payload) + if (result?.type === "refresh") { + useGlobalSyncStore.setState({ reload: "pending" }) + } else if (result?.type === "project") { + const current = useGlobalSyncStore.getState() + useGlobalSyncStore.setState({ + projects: applyGlobalProject(current, result.project).projects, + }) + } + return + } + + childStores.mark(directory) + + // Notification dispatch for session turn-complete and error events. + // These are NOT handled by the event reducer — only the notification store. + if (payload.type === "session.idle" || payload.type === "session.error") { + const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } } + const sessionID = props.sessionID + // Skip subtask sessions — only top-level sessions generate notifications + const storeState = store.getState() + const session = storeState.session.find((s) => s.id === sessionID) + if (session && (session as { parentID?: string }).parentID) { + // subtask — skip notification + } else if (sessionID) { + appendNotification({ + directory, + session: sessionID, + time: Date.now(), + viewed: isViewedInCurrentSession(directory, sessionID), + ...(payload.type === "session.error" + ? { type: "error" as const, error: props.error } + : { type: "turn-complete" as const }), + }) + } + } + + // Read live state, create targeted draft cloning ONLY fields the event + // type will mutate. This preserves reference identity for untouched slices + // so Zustand selectors skip re-renders for unrelated subscribers. + const current = store.getState() + const draft: State = { ...current } + + switch (payload.type) { + case "session.created": + case "session.updated": + case "session.deleted": + draft.session = [...current.session] + draft.permission = { ...current.permission } + draft.todo = { ...current.todo } + draft.part = { ...current.part } + break + case "session.diff": + draft.session_diff = { ...current.session_diff } + break + case "session.status": + draft.session_status = { ...(current.session_status ?? {}) } + break + case "todo.updated": + draft.todo = { ...current.todo } + break + case "message.updated": + draft.message = { ...current.message } + break + case "message.removed": + draft.message = { ...current.message } + draft.part = { ...current.part } + break + case "message.part.updated": + case "message.part.removed": + case "message.part.delta": + draft.part = { ...current.part } + break + case "vcs.branch.updated": + break + case "permission.asked": + case "permission.replied": + draft.permission = { ...current.permission } + break + case "question.asked": + case "question.replied": + case "question.rejected": + draft.question = { ...current.question } + break + case "lsp.updated": + draft.lsp = [...current.lsp] + break + default: + break + } + + if (applyDirectoryEvent(draft, payload)) { + store.setState(draft) + } + + // Update global session status for cross-directory sidebar visibility + if (payload.type === "session.status") { + const props = payload.properties as { sessionID: string; status: SessionStatus } + setGlobalSessionStatus(props.sessionID, props.status) + } + + if (payload.type === "permission.asked") { + const normalizedDirectory = normalizeDirectory(directory) + if (!normalizedDirectory) { + return + } + + const permission = payload.properties as PermissionRequest + const sessions = store.getState().session + const autoAccept = usePermissionStore.getState().autoAccept + if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: normalizedDirectory })) { + void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) + } + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function SyncProvider(props: { + sdk: OpencodeClient + directory: string + children: React.ReactNode +}) { + const childStoresRef = useRef(null) + if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() + const childStores = childStoresRef.current + + const system = useMemo( + () => ({ + childStores, + sdk: props.sdk, + directory: props.directory, + }), + [childStores, props.sdk, props.directory], + ) + + // Configure child store manager + useEffect(() => { + const bootingDirs = new Set() + + childStores.configure({ + onBootstrap: (directory) => { + if (bootingDirs.has(directory)) return + bootingDirs.add(directory) + + const store = childStores.getChild(directory) + if (!store) return + + const runBootstrap = async (attempt: number) => { + const globalState = useGlobalSyncStore.getState() + await bootstrapDirectory({ + directory, + sdk: props.sdk, + getState: () => store.getState(), + set: (patch) => { + store.setState(patch) + if (patch.session_status) { + const current = useGlobalSessionStatusStore.getState().statuses + const merged = { ...current, ...patch.session_status } + useGlobalSessionStatusStore.setState({ statuses: merged }) + } + }, + global: { + config: globalState.config, + projects: globalState.projects, + providers: globalState.providers, + }, + loadSessions: (dir) => retry(async () => { + const result = await props.sdk.session.list({ + directory: dir, + roots: true, + limit: 50, + }) + // SDK returns { error } instead of { data } on non-ok responses (503). + // Throw so retry() retries and allSettled marks it as rejected. + if ((result as { error?: unknown }).error) { + throw new Error("session.list failed: " + String((result as { error?: unknown }).error)) + } + const sessions = (result.data ?? []) + .filter((s) => !!s?.id) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) }) + }), + }) + + // VS Code race: if sessions are still empty after bootstrap, OpenCode + // wasn't ready yet (bridge returned 503). Retry a few times. + const state = store.getState() + if (state.session.length === 0 && attempt < 5) { + await new Promise((r) => setTimeout(r, 2000)) + store.setState({ status: "loading" as const }) + await runBootstrap(attempt + 1) + } + } + + runBootstrap(0).finally(() => { + bootingDirs.delete(directory) + }) + }, + onDispose: (directory) => { + bootingDirs.delete(directory) + }, + isBooting: (directory) => bootingDirs.has(directory), + isLoadingSessions: () => false, + }) + }, [childStores, props.sdk]) + + // Bootstrap global state — set bootingRoot/bootedAt to suppress + // redundant refresh events during startup + useEffect(() => { + bootingRoot = true + const globalActions = useGlobalSyncStore.getState().actions + bootstrapGlobal(props.sdk, globalActions.set) + .then(() => { + bootedAt = Date.now() + }) + .finally(() => { + bootingRoot = false + }) + }, [props.sdk]) + + // Event pipeline — created once per mount. No class, no start/stop. + // Abort controller owned by the pipeline closure. Cleanup aborts + flushes. + useEffect(() => { + const { cleanup } = createEventPipeline({ + sdk: props.sdk, + onEvent: (directory, payload) => { + handleEvent(directory, payload, childStores) + }, + }) + return cleanup + }, [props.sdk, childStores]) + + // Ensure current directory's child store exists + useEffect(() => { + if (props.directory) { + childStores.ensureChild(props.directory) + } + }, [props.directory, childStores]) + + // Set refs so non-React code (session-actions, session-ui-store) can access sync state + useEffect(() => { + setSyncRefs(props.sdk, childStores, props.directory) + setActionRefs( + props.sdk, + childStores, + () => opencodeClient.getDirectory() || props.directory, + ) + }, [props.sdk, props.directory, childStores]) + + // Subscribe to child store for streaming state derivation + useEffect(() => { + if (!props.directory) return + const store = childStores.getChild(props.directory) + if (!store) return + const unsubscribe = store.subscribe((state) => { + updateStreamingState(state) + }) + return unsubscribe + }, [props.directory, childStores]) + + return {props.children} +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** Access the global sync store */ +export function useGlobalSync() { + return useGlobalSyncStore() +} + +/** Access the global sync store with a selector */ +export function useGlobalSyncSelector(selector: (state: GlobalSyncStore) => T): T { + return useGlobalSyncStore(selector) +} + +/** Get the child store for a directory (defaults to current) */ +export function useDirectoryStore(directory?: string): StoreApi { + const system = useSyncSystem() + const dir = directory ?? system.directory + return system.childStores.ensureChild(dir) +} + +/** Select from the current directory's store */ +export function useDirectorySync(selector: (state: State) => T, directory?: string): T { + const store = useDirectoryStore(directory) + return useStore(store, selector) +} + +/** Get the revert messageID for a session (if reverted) */ +export function useSessionRevertMessageID(sessionID: string, directory?: string): string | undefined { + return useDirectorySync( + useCallback((state: State) => { + const session = state.session.find((s) => s.id === sessionID) + return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID + }, [sessionID]), + directory, + ) +} + +/** Get session messages for a specific session */ +export function useSessionMessages(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]), + directory, + ) +} + +/** + * Get visible session messages — filters out reverted messages. + * Filters out reverted messages (id >= session.revert.messageID). + */ +export function useVisibleSessionMessages(sessionID: string, directory?: string) { + const messages = useSessionMessages(sessionID, directory) + const revertMessageID = useSessionRevertMessageID(sessionID, directory) + return useMemo(() => { + if (!revertMessageID) return messages + return messages.filter((m) => m.id < revertMessageID) + }, [messages, revertMessageID]) +} + +/** Get parts for a specific message */ +export function useSessionParts(messageID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.part[messageID] ?? EMPTY_PARTS, [messageID]), + directory, + ) +} + +/** Get status for a specific session */ +export function useSessionStatus(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.session_status?.[sessionID], [sessionID]), + directory, + ) +} + +/** Get permissions for a specific session */ +export function useSessionPermissions(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]), + directory, + ) +} + +/** Get questions for a specific session */ +export function useSessionQuestions(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.question[sessionID] ?? EMPTY_QUESTION_REQUESTS, [sessionID]), + directory, + ) +} + +/** Get sessions list for a directory */ +export function useSessions(directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.session, []), + directory, + ) +} + +/** Get the SDK client */ +export function useSyncSDK() { + return useSyncSystem().sdk +} + +/** Get the current directory */ +export function useSyncDirectory() { + return useSyncSystem().directory +} + +/** Get the child store manager (for advanced operations) */ +export function useChildStoreManager() { + return useSyncSystem().childStores +} + +/** + * Get messages for a session in the old {info, parts}[] format. + * Uses visible messages (filtered by revert state). + * + * Uses a ref-stable parts lookup that only triggers re-renders when + * a part array for one of our displayed messages actually changes. + */ +export function useSessionMessageRecords(sessionID: string, directory?: string) { + const messages = useVisibleSessionMessages(sessionID, directory) + const store = useDirectoryStore(directory) + + // Track parts with a ref to avoid subscribing to entire state.part map. + // Re-derive only when messages list changes or on store subscription. + const prevPartsRef = useRef>({}) + const [partsSnapshot, setPartsSnapshot] = React.useState>({}) + + React.useEffect(() => { + const messageIds = messages.map((m) => m.id) + let timer: ReturnType | null = null + let pending = false + + const flush = () => { + timer = null + pending = false + const state = store.getState() + const prev = prevPartsRef.current + let changed = false + const next: Record = {} + for (const id of messageIds) { + const parts = state.part[id] ?? EMPTY_PARTS + // Preserve existing reference if parts haven't changed in the store + next[id] = prev[id] === parts ? prev[id] : parts + if (next[id] !== prev[id]) changed = true + } + if (changed || Object.keys(prev).length !== messageIds.length) { + prevPartsRef.current = next + setPartsSnapshot(next) + } + } + + // Initial sync + flush() + + // Throttled subscription — batch rapid delta events into ~100ms updates + const unsub = store.subscribe(() => { + if (timer) { + pending = true + return + } + timer = setTimeout(() => { + flush() + if (pending) { + pending = false + timer = setTimeout(flush, 100) + } + }, 100) + }) + + return () => { + unsub() + if (timer) clearTimeout(timer) + } + }, [messages, store]) + + return useMemo( + () => messages.map((msg) => ({ + info: msg, + parts: partsSnapshot[msg.id] ?? EMPTY_PARTS, + })), + [messages, partsSnapshot], + ) +} + +/** + * Determines if a session is actively working. + * Checks session_status AND incomplete assistant messages as fallback. + * Returns false when permissions are pending (permission indicator takes priority). + */ +export function useIsSessionWorking(sessionID: string, directory?: string): boolean { + const status = useSessionStatus(sessionID, directory) + const permissions = useSessionPermissions(sessionID, directory) + const messages = useSessionMessages(sessionID, directory) + + return useMemo(() => { + // Permissions pending → not "working" (show permission indicator instead) + if (permissions.length > 0) return false + + // Check session_status + const statusWorking = status !== undefined && status.type !== "idle" + + // Check for incomplete assistant message (fallback if status event delayed) + let hasPendingAssistant = false + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.role === "assistant" && typeof (m as { time?: { completed?: number } }).time?.completed !== "number") { + hasPendingAssistant = true + break + } + } + + return statusWorking || hasPendingAssistant + }, [status, permissions, messages]) +} + +const EMPTY_MESSAGES: Message[] = [] +const EMPTY_PARTS: Part[] = [] +const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] +const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = [] diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts new file mode 100644 index 00000000..a5d21c19 --- /dev/null +++ b/packages/ui/src/sync/sync-refs.ts @@ -0,0 +1,92 @@ +/** + * Sync refs — imperative access to sync state from non-React code. + * + * SyncProvider sets these refs on mount. Store actions (session-ui-store, + * session-actions) use them to read child-store domain data without hooks. + */ + +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { ChildStoreManager } from "./child-store" +import type { State } from "./types" + +let _sdk: OpencodeClient | null = null +let _childStores: ChildStoreManager | null = null +let _directory: string = "" + +export function setSyncRefs( + sdk: OpencodeClient, + childStores: ChildStoreManager, + directory: string, +) { + _sdk = sdk + _childStores = childStores + _directory = directory +} + +export function getSyncSDK(): OpencodeClient { + if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?") + return _sdk +} + +export function getSyncChildStores(): ChildStoreManager { + if (!_childStores) throw new Error("ChildStoreManager not initialized — is SyncProvider mounted?") + return _childStores +} + +export function getSyncDirectory(): string { + return _directory +} + +/** Read current directory's child store state. Returns undefined if not bootstrapped. */ +export function getDirectoryState(directory?: string): State | undefined { + const stores = _childStores + if (!stores) return undefined + const dir = directory || _directory + if (!dir) return undefined + return stores.getState(dir) +} + +/** Read sessions from current directory's child store */ +export function getSyncSessions(directory?: string) { + return getDirectoryState(directory)?.session ?? [] +} + +/** Read sessions across all initialized child stores */ +export function getAllSyncSessions() { + const stores = _childStores + if (!stores) return [] + + const deduped = new Map() + for (const store of stores.children.values()) { + for (const session of store.getState().session) { + if (!session?.id) continue + deduped.set(session.id, session) + } + } + return Array.from(deduped.values()) +} + +/** Read messages for a session from current directory's child store */ +export function getSyncMessages(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.message[sessionId] ?? [] +} + +/** Read parts for a message from current directory's child store */ +export function getSyncParts(messageId: string, directory?: string) { + return getDirectoryState(directory)?.part[messageId] ?? [] +} + +/** Read session status from current directory's child store */ +export function getSyncSessionStatus(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.session_status[sessionId] +} + +/** Read permissions for a session from current directory's child store */ +export function getSyncPermissions(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.permission[sessionId] ?? [] +} + +/** Read questions for a session from current directory's child store */ +export function getSyncQuestions(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.question[sessionId] ?? [] +} diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts new file mode 100644 index 00000000..99262737 --- /dev/null +++ b/packages/ui/src/sync/types.ts @@ -0,0 +1,142 @@ +import type { + Agent, + Command, + Config, + FileDiff, + LspStatus, + McpStatus, + Message, + Part, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + ProviderListResponse, + QuestionRequest, + Session, + SessionStatus, + Todo, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" + +export type ProjectMeta = { + name?: string + icon?: { + override?: string + color?: string + } + commands?: { + start?: string + } +} + +/** Per-directory store state */ +export type State = { + status: "loading" | "partial" | "complete" + agent: Agent[] + command: Command[] + project: string + projectMeta: ProjectMeta | undefined + icon: string | undefined + provider: ProviderListResponse + config: Config + path: Path + session: Session[] + sessionTotal: number + session_status: Record + session_diff: Record + todo: Record + permission: Record + question: Record + mcp: Record + lsp: LspStatus[] + vcs: VcsInfo | undefined + limit: number + message: Record + part: Record +} + +/** Global store state */ +export type GlobalState = { + ready: boolean + error?: InitError + path: Path + projects: Project[] + providers: ProviderListResponse + providerAuth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" + sessionTodo: Record +} + +export type InitError = { + type: "init" + message: string +} + +export type DirState = { + lastAccessAt: number +} + +export type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export type ChildOptions = { + bootstrap?: boolean +} + +export const MAX_DIR_STORES = 30 +export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 +export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 +export const SESSION_RECENT_LIMIT = 50 +export const SESSION_CACHE_LIMIT = 8 + +export const INITIAL_STATE: State = { + project: "", + projectMeta: undefined, + icon: undefined, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading", + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, +} + +export const INITIAL_GLOBAL_STATE: GlobalState = { + ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + projects: [], + providers: { all: [], connected: [], default: {} }, + providerAuth: {}, + config: {}, + reload: undefined, + sessionTodo: {}, +} diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts new file mode 100644 index 00000000..eda18483 --- /dev/null +++ b/packages/ui/src/sync/use-sync.ts @@ -0,0 +1,413 @@ +import { useCallback, useRef, useMemo } from "react" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import { retry } from "./retry" +import { SESSION_CACHE_LIMIT } from "./types" +import { pickSessionCacheEvictions } from "./session-cache" +import { + mergeOptimisticPage, + mergeMessages, + type OptimisticItem, +} from "./optimistic" +import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context" +import { dropSessionCaches } from "./session-cache" +import { stripMessageDiffSnapshots } from "./sanitize" +import { + shouldSkipSessionPrefetch, + getSessionPrefetch, + setSessionPrefetch, + clearSessionPrefetch, +} from "./session-prefetch-cache" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const MESSAGE_PAGE_SIZE = 200 +const MAX_SEEN_DIRS = 30 +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function sortParts(parts: Part[]) { + return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) +} + +// --------------------------------------------------------------------------- +// useSync — message loading, pagination, optimistic updates +// Message loading, pagination, optimistic updates +// --------------------------------------------------------------------------- + +export function useSync() { + const sdk = useSyncSDK() + const directory = useSyncDirectory() + const store = useDirectoryStore() + const childStores = useChildStoreManager() + + // Refs for mutable tracking (no re-renders) + const inflight = useRef(new Map>()) + const optimistic = useRef(new Map>()) + const seen = useRef(new Map>()) + const meta = useRef(new Map()) + + const keyFor = useCallback( + (sessionID: string) => `${directory}\n${sessionID}`, + [directory], + ) + + const getMetaFor = useCallback( + (sessionID: string) => { + const key = keyFor(sessionID) + return meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } + }, + [keyFor], + ) + + const setMetaFor = useCallback( + (sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => { + const key = keyFor(sessionID) + const current = meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } + meta.current.set(key, { ...current, ...patch }) + }, + [keyFor], + ) + + // Session cache eviction — two levels of LRU: + // (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT). + + // Evict all cached session data for given IDs from a directory's store + const evict = useCallback( + (dir: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + const dirStore = childStores.getChild(dir) + if (!dirStore) return + + const current = dirStore.getState() + const draft = { + message: { ...current.message }, + part: { ...current.part }, + session_status: { ...current.session_status }, + session_diff: { ...current.session_diff }, + todo: { ...current.todo }, + permission: { ...current.permission }, + question: { ...current.question }, + } + dropSessionCaches(draft, sessionIDs) + dirStore.setState(draft) + + // Clear meta + optimistic + prefetch cache for evicted sessions + for (const id of sessionIDs) { + optimistic.current.delete(`${dir}\n${id}`) + meta.current.delete(`${dir}\n${id}`) + } + clearSessionPrefetch(dir, sessionIDs) + }, + [childStores], + ) + + // Get or create the seen-set for a directory. LRU reorder on access. + // When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches. + // LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS. + const seenFor = useCallback(() => { + const existing = seen.current.get(directory) + if (existing) { + // LRU reorder: delete + re-insert moves to end (most recent) + seen.current.delete(directory) + seen.current.set(directory, existing) + return existing + } + const created = new Set() + seen.current.set(directory, created) + + // Evict oldest directories if over limit + while (seen.current.size > MAX_SEEN_DIRS) { + const first = seen.current.keys().next().value + if (!first) break + const staleSessionIds = [...(seen.current.get(first) ?? [])] + seen.current.delete(first) + evict(first, staleSessionIds) + } + + return created + }, [directory, evict]) + + // Touch a session — triggers both directory-level and session-level eviction + const touch = useCallback( + (sessionID: string) => { + const s = seenFor() + const stale = pickSessionCacheEvictions({ + seen: s, + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, stale) + }, + [directory, seenFor, evict], + ) + + // Optimistic operations + const getOptimistic = useCallback( + (sessionID: string): OptimisticItem[] => { + const key = `${directory}\n${sessionID}` + return [...(optimistic.current.get(key)?.values() ?? [])] + }, + [directory], + ) + + const setOptimistic = useCallback( + (sessionID: string, item: OptimisticItem) => { + const key = `${directory}\n${sessionID}` + const list = optimistic.current.get(key) + const sorted: OptimisticItem = { message: item.message, parts: sortParts(item.parts) } + if (list) { + list.set(item.message.id, sorted) + } else { + optimistic.current.set(key, new Map([[item.message.id, sorted]])) + } + }, + [directory], + ) + + const clearOptimistic = useCallback( + (sessionID: string, messageID?: string) => { + const key = `${directory}\n${sessionID}` + if (!messageID) { + optimistic.current.delete(key) + return + } + const list = optimistic.current.get(key) + if (!list) return + list.delete(messageID) + if (list.size === 0) optimistic.current.delete(key) + }, + [directory], + ) + + // Fetch messages from API + const fetchMessages = useCallback( + async (sessionID: string, limit: number, before?: string) => { + const result = await retry(() => + sdk.session.messages({ sessionID, limit, before }), + ) + const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id) + const session = items + .map((x: { info: Message }) => stripMessageDiffSnapshots(x.info)) + .sort((a: Message, b: Message) => cmp(a.id, b.id)) + const part = items.map((x: { info: { id: string }; parts: Part[] }) => ({ + id: x.info.id, + part: sortParts(x.parts), + })) + const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined + return { session, part, cursor, complete: !cursor } + }, + [sdk], + ) + + // Load messages for a session + const loadMessages = useCallback( + async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => { + const m = getMetaFor(sessionID) + if (m.loading) return + setMetaFor(sessionID, { loading: true }) + + try { + const limit = m.limit + const page = await fetchMessages(sessionID, limit, options?.before) + + // Merge optimistic items + const items = getOptimistic(sessionID) + const merged = mergeOptimisticPage(page, items) + for (const messageID of merged.confirmed) { + clearOptimistic(sessionID, messageID) + } + + const current = store.getState() + const cached = options?.mode === "prepend" ? (current.message[sessionID] ?? []) : [] + const messages = options?.mode === "prepend" + ? mergeMessages(cached, merged.session) + : merged.session + + // Build part updates — preserve existing references on prepend to avoid flicker + const isPrepend = options?.mode === "prepend" + let partsChanged = false + const partUpdate: Record = { ...current.part } + for (const p of merged.part) { + if (isPrepend && partUpdate[p.id]) continue // already loaded + const filtered = p.part.filter((x: Part) => !SKIP_PARTS.has(x.type)) + if (filtered.length) { + partUpdate[p.id] = filtered + partsChanged = true + } + } + + const patch: Record = { + message: messages !== cached ? { ...current.message, [sessionID]: messages } : current.message, + } + if (!isPrepend || partsChanged) { + patch.part = partUpdate + } + store.setState(patch) + setMetaFor(sessionID, { + limit: messages.length, + cursor: merged.cursor, + complete: merged.complete, + loading: false, + }) + setSessionPrefetch({ + directory, + sessionID, + limit: messages.length, + cursor: merged.cursor, + complete: merged.complete, + }) + } catch { + setMetaFor(sessionID, { loading: false }) + } + }, + [store, fetchMessages, getMetaFor, setMetaFor, getOptimistic, clearOptimistic, directory], + ) + + // Sync a session (load if not cached) + const syncSession = useCallback( + async (sessionID: string, force?: boolean) => { + touch(sessionID) + const key = keyFor(sessionID) + + // Dedup inflight requests + const existing = inflight.current.get(key) + if (existing) return existing + + const current = store.getState() + const m = getMetaFor(sessionID) + const cached = current.message[sessionID] !== undefined && m.limit > 0 + const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found + if (cached && hasSession && !force) return + + // Skip if recently fetched (TTL) + if (!force) { + const prefetchInfo = getSessionPrefetch(directory, sessionID) + if (shouldSkipSessionPrefetch({ + hasMessages: cached, + info: prefetchInfo, + pageSize: MESSAGE_PAGE_SIZE, + })) return + } + + const promise = (async () => { + // Fetch session info if needed + if (!hasSession || force) { + try { + const result = await retry(() => sdk.session.get({ sessionID })) + if (result.data) { + const s = store.getState() + const sessions = [...s.session] + const idx = Binary.search(sessions, sessionID, (s) => s.id) + if (idx.found) { + sessions[idx.index] = result.data + } else { + sessions.splice(idx.index, 0, result.data) + } + store.setState({ session: sessions }) + } + } catch (e) { + console.error("[sync] failed to fetch session", sessionID, e) + } + } + + // Load messages if needed + if (!cached || force) { + await loadMessages(sessionID) + } + })() + + inflight.current.set(key, promise) + promise.finally(() => inflight.current.delete(key)) + return promise + }, + [store, sdk, keyFor, touch, getMetaFor, loadMessages, directory], + ) + + // Load more (pagination) + const loadMore = useCallback( + async (sessionID: string) => { + touch(sessionID) + const m = getMetaFor(sessionID) + if (m.loading || m.complete || !m.cursor) return + await loadMessages(sessionID, { before: m.cursor, mode: "prepend" }) + }, + [touch, getMetaFor, loadMessages], + ) + + const hasMore = useCallback( + (sessionID: string) => { + const m = getMetaFor(sessionID) + return !m.complete && !!m.cursor + }, + [getMetaFor], + ) + + const isLoading = useCallback( + (sessionID: string) => getMetaFor(sessionID).loading, + [getMetaFor], + ) + + // Optimistic add (for prompt submission) + const optimisticAdd = useCallback( + (input: { sessionID: string; message: Message; parts: Part[] }) => { + setOptimistic(input.sessionID, { message: input.message, parts: input.parts }) + const current = store.getState() + const message = { ...current.message } + const part = { ...current.part } + + // Insert message + const messages = message[input.sessionID] ? [...message[input.sessionID]] : [] + const result = Binary.search(messages, input.message.id, (m) => m.id) + if (!result.found) messages.splice(result.index, 0, input.message) + message[input.sessionID] = messages + + // Insert parts + part[input.message.id] = sortParts(input.parts) + + store.setState({ message, part }) + }, + [store, setOptimistic], + ) + + // Optimistic remove (for rollback on error) + const optimisticRemove = useCallback( + (input: { sessionID: string; messageID: string }) => { + clearOptimistic(input.sessionID, input.messageID) + const current = store.getState() + const message = { ...current.message } + const part = { ...current.part } + + const messages = message[input.sessionID] + if (messages) { + const next = [...messages] + const result = Binary.search(next, input.messageID, (m) => m.id) + if (result.found) { + next.splice(result.index, 1) + message[input.sessionID] = next + } + } + delete part[input.messageID] + + store.setState({ message, part }) + }, + [store, clearOptimistic], + ) + + return useMemo( + () => ({ + syncSession, + loadMore, + hasMore, + isLoading, + optimistic: { + add: optimisticAdd, + remove: optimisticRemove, + }, + }), + [syncSession, loadMore, hasMore, isLoading, optimisticAdd, optimisticRemove], + ) +} diff --git a/packages/ui/src/sync/viewport-store.ts b/packages/ui/src/sync/viewport-store.ts new file mode 100644 index 00000000..2a7a03d9 --- /dev/null +++ b/packages/ui/src/sync/viewport-store.ts @@ -0,0 +1,49 @@ +/** + * Viewport Store — per-session scroll anchors, streaming state, memory. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type SessionMemoryState = { + viewportAnchor: number + isStreaming: boolean + streamStartTime?: number + lastAccessedAt: number + backgroundMessageCount: number + loadedTurnCount?: number + hasMoreAbove?: boolean + hasMoreTurnsAbove?: boolean + historyLoading?: boolean + historyComplete?: boolean + historyLimit?: number + totalAvailableMessages?: number + streamingCooldownUntil?: number + isZombie?: boolean + lastUserMessageAt?: number +} + +export type ViewportState = { + sessionMemoryState: Map + isSyncing: boolean + + updateViewportAnchor: (sessionId: string, anchor: number) => void +} + +export const useViewportStore = create()((set) => ({ + sessionMemoryState: new Map(), + isSyncing: false, + + updateViewportAnchor: (sessionId, anchor) => + set((s) => { + const map = new Map(s.sessionMemoryState) + const existing = map.get(sessionId) ?? { + viewportAnchor: 0, + isStreaming: false, + lastAccessedAt: Date.now(), + backgroundMessageCount: 0, + } + map.set(sessionId, { ...existing, viewportAnchor: anchor, lastAccessedAt: Date.now() }) + return { sessionMemoryState: map } + }), +})) diff --git a/packages/ui/src/sync/voice-store.ts b/packages/ui/src/sync/voice-store.ts new file mode 100644 index 00000000..494abfce --- /dev/null +++ b/packages/ui/src/sync/voice-store.ts @@ -0,0 +1,23 @@ +/** + * Voice Store — voice connection and activity state. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type VoiceStatus = "disconnected" | "connecting" | "connected" | "error" +export type VoiceMode = "idle" | "speaking" | "listening" + +export type VoiceState = { + voiceStatus: VoiceStatus + voiceMode: VoiceMode + setVoiceStatus: (status: VoiceStatus) => void + setVoiceMode: (mode: VoiceMode) => void +} + +export const useVoiceStore = create()((set) => ({ + voiceStatus: "disconnected", + voiceMode: "idle", + setVoiceStatus: (status) => set({ voiceStatus: status }), + setVoiceMode: (mode) => set({ voiceMode: mode }), +})) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 702e3a32..062d93d5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -243,7 +243,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md new file mode 100644 index 00000000..9f8dd3dd --- /dev/null +++ b/packages/vscode/src/DOCUMENTATION.md @@ -0,0 +1,61 @@ +# VS Code Backend Modules + +This document describes backend runtime modules used by the VS Code extension bridge (`packages/vscode/src/bridge.ts`). + +## Purpose + +Keep `bridge.ts` as a thin orchestration layer that delegates message handling to cohesive domain runtimes while preserving API behavior. + +## Runtime modules + +- `bridge.ts` + - Entry orchestration layer for bridge messages. + - Delegates to specialized runtimes in order and handles only unmatched fallthrough cases. + +- `bridge-git-runtime.ts` + - Standard Git message handlers. + +- `bridge-git-special-runtime.ts` + - Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers. + +- `bridge-git-process-runtime.ts` + - Git process execution and environment setup (`execGit`), including SSH agent socket resolution. + +- `bridge-fs-runtime.ts` + - Bridge handlers for filesystem-related message routes. + - Uses shared FS helpers via injected dependencies. + +- `bridge-fs-helpers-runtime.ts` + - Filesystem/path/search helper functions: + - path normalization and resolution + - directory listing + - file search + - file read path safety checks + - dropped-file parsing and attachment reading + - models metadata fetch helper + +- `bridge-localfs-proxy-runtime.ts` + - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. + +- `bridge-proxy-runtime.ts` + - Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies. + +- `bridge-config-runtime.ts` + - Config and skills message handlers (`api:config/*`). + - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). + +- `bridge-settings-runtime.ts` + - Settings read/write and OpenCode skills discovery via API for bridge consumers. + +- `bridge-system-runtime.ts` + - System/editor/provider/quota/notification/update-check message handlers. + - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). + - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). + +## Extension guideline + +When adding new bridge route families: + +1. Prefer creating or extending a domain runtime module under `packages/vscode/src/bridge-*-runtime.ts`. +2. Keep `bridge.ts` focused on delegation order and minimal fallthrough behavior. +3. Inject dependencies into runtimes instead of reaching into unrelated modules directly. diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts new file mode 100644 index 00000000..e28a9bea --- /dev/null +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -0,0 +1,595 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { + createAgent, + createCommand, + deleteAgent, + deleteCommand, + getAgentSources, + getCommandSources, + updateAgent, + updateCommand, + type AgentScope, + type CommandScope, + AGENT_SCOPE, + COMMAND_SCOPE, + discoverSkills, + getSkillSources, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + type SkillScope, + type DiscoveredSkill, + SKILL_SCOPE, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, +} from './opencodeConfig'; +import { + getSkillsCatalog, + scanSkillsRepository as scanSkillsRepositoryFromGit, + installSkillsFromRepository as installSkillsFromGit, + type SkillsCatalogSourceConfig, +} from './skillsCatalog'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ConfigRuntimeDeps = { + readSettings: (ctx?: BridgeContext) => Record; + persistSettings: (changes: Record, ctx?: BridgeContext) => Promise>; + fetchOpenCodeSkillsFromApi: (ctx: BridgeContext | undefined, workingDirectory?: string) => Promise; + clientReloadDelayMs: number; +}; + +const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => ( + (typeof directory === 'string' && directory.trim()) + ? directory.trim() + : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) +); + +const parseSkillsCatalogSources = (settings: Record): SkillsCatalogSourceConfig[] => { + const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; + if (!Array.isArray(rawCatalogs)) { + return []; + } + + return rawCatalogs + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const candidate = entry as Record; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; + const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; + if (!id || !label || !source) return null; + const normalized: SkillsCatalogSourceConfig = { + id, + label, + description: source, + source, + ...(subpath ? { defaultSubpath: subpath } : {}), + }; + return normalized; + }) + .filter((value): value is SkillsCatalogSourceConfig => value !== null); +}; + +export async function handleConfigBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: ConfigRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:config/opencode-resolution:get': { + const debugInfo = ctx?.manager?.getDebugInfo(); + const configuredFromWorkspace = vscode.workspace.getConfiguration('openchamber').get('opencodeBinary'); + const configured = typeof configuredFromWorkspace === 'string' && configuredFromWorkspace.trim().length > 0 + ? configuredFromWorkspace.trim() + : null; + const resolved = debugInfo?.cliPath ?? null; + const source = (() => { + if (!resolved) return null; + if (configured && configured === resolved) return 'settings'; + const envBinary = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; + if (envBinary && envBinary === resolved) return 'env'; + return 'path'; + })(); + + return { + id, + type, + success: true, + data: { + configured, + resolved, + resolvedDir: resolved ? path.dirname(resolved) : null, + source, + detectedNow: resolved, + detectedSourceNow: source, + shim: null, + viaWsl: false, + wslBinary: null, + wslPath: null, + wslDistro: null, + node: process.execPath || null, + bun: null, + }, + }; + } + + case 'api:config/settings:get': { + const settings = deps.readSettings(ctx); + return { id, type, success: true, data: settings }; + } + + case 'api:config/settings:save': { + const changes = (payload as Record) || {}; + const updated = await deps.persistSettings(changes, ctx); + return { id, type, success: true, data: updated }; + } + + case 'api:config/reload': { + await ctx?.manager?.restart(); + return { id, type, success: true, data: { restarted: true } }; + } + + case 'api:config/agents': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const agentName = typeof name === 'string' ? name.trim() : ''; + if (!agentName) { + return { id, type, success: false, error: 'Agent name is required' }; + } + + const workingDirectory = resolveWorkingDirectory(ctx, directory); + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const sources = getAgentSources(agentName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + return { + id, + type, + success: true, + data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; + createAgent(agentName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateAgent(agentName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteAgent(agentName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/commands': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const commandName = typeof name === 'string' ? name.trim() : ''; + if (!commandName) { + return { id, type, success: false, error: 'Command name is required' }; + } + + const workingDirectory = resolveWorkingDirectory(ctx, directory); + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const sources = getCommandSources(commandName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + return { + id, + type, + success: true, + data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; + createCommand(commandName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateCommand(commandName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteCommand(commandName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/mcp': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const mcpName = typeof name === 'string' ? name.trim() : ''; + const workingDirectory = resolveWorkingDirectory(ctx, directory); + + if (normalizedMethod === 'GET' && !mcpName) { + const configs = listMcpConfigs(workingDirectory); + return { id, type, success: true, data: configs }; + } + + if (!mcpName) { + return { id, type, success: false, error: 'MCP server name is required' }; + } + + if (normalizedMethod === 'GET') { + const config = getMcpConfig(mcpName, workingDirectory); + if (!config) { + return { id, type, success: false, error: `MCP server "${mcpName}" not found` }; + } + return { id, type, success: true, data: config }; + } + + if (normalizedMethod === 'POST') { + const scope = body?.scope as 'user' | 'project' | undefined; + createMcpConfig(mcpName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" created. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateMcpConfig(mcpName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" updated. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteMcpConfig(mcpName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" deleted. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/skills': { + const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (!name && normalizedMethod === 'GET') { + const skills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory); + return { id, type, success: true, data: { skills } }; + } + + const skillName = typeof name === 'string' ? name.trim() : ''; + if (!skillName) { + return { id, type, success: false, error: 'Skill name is required' }; + } + + if (normalizedMethod === 'GET') { + const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) + .find((skill) => skill.name === skillName); + const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); + return { + id, + type, + success: true, + data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const sourceValue = body?.source as string | undefined; + const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; + const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; + createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateSkill(skillName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteSkill(skillName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/skills:catalog': { + const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const settings = deps.readSettings(ctx); + const additionalSources = parseSkillsCatalogSources(settings); + const installedSkills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined; + const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills); + return { id, type, success: true, data }; + } + + case 'api:config/skills:scan': { + const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; + const data = await scanSkillsRepositoryFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + }); + return { id, type, success: true, data }; + } + + case 'api:config/skills:install': { + const body = (payload || {}) as { + source?: string; + subpath?: string; + scope?: 'user' | 'project'; + targetSource?: 'opencode' | 'agents'; + selections?: Array<{ skillDir: string }>; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; + }; + + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const data = await installSkillsFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + scope: body.scope === 'project' ? 'project' : 'user', + targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode', + workingDirectory: body.scope === 'project' ? workingDirectory : undefined, + selections: Array.isArray(body.selections) ? body.selections : [], + conflictPolicy: body.conflictPolicy, + conflictDecisions: body.conflictDecisions, + }); + + if (data.ok) { + const installed = data.installed || []; + const skipped = data.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await ctx?.manager?.restart(); + } + + return { + id, + type, + success: true, + data: { + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined, + }, + }; + } + + return { id, type, success: true, data }; + } + + case 'api:config/skills/files': { + const { method, name, filePath, content } = (payload || {}) as { + method?: string; + name?: string; + filePath?: string; + content?: string; + }; + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const skillName = typeof name === 'string' ? name.trim() : ''; + if (!skillName) { + return { id, type, success: false, error: 'Skill name is required' }; + } + + const relativePath = typeof filePath === 'string' ? filePath.trim() : ''; + if (!relativePath) { + return { id, type, success: false, error: 'File path is required' }; + } + + const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) + .find((skill) => skill.name === skillName); + const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); + if (!sources.md.dir) { + return { id, type, success: false, error: `Skill "${skillName}" not found` }; + } + + const skillDir = sources.md.dir; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const fileContent = readSkillSupportingFile(skillDir, relativePath); + if (fileContent === null) { + return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` }; + } + return { id, type, success: true, data: { content: fileContent } }; + } + + if (normalizedMethod === 'PUT') { + writeSkillSupportingFile(skillDir, relativePath, content || ''); + return { id, type, success: true, data: { success: true } }; + } + + if (normalizedMethod === 'DELETE') { + deleteSkillSupportingFile(skillDir, relativePath); + return { id, type, success: true, data: { success: true } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-fs-helpers-runtime.ts b/packages/vscode/src/bridge-fs-helpers-runtime.ts new file mode 100644 index 00000000..847c0b3d --- /dev/null +++ b/packages/vscode/src/bridge-fs-helpers-runtime.ts @@ -0,0 +1,563 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { execGit } from './bridge-git-process-runtime'; + +const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; + +const guessMimeTypeFromExtension = (ext: string) => { + switch (ext) { + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.bmp': + case '.webp': + return `image/${ext.replace('.', '')}`; + case '.pdf': + return 'application/pdf'; + case '.txt': + case '.log': + return 'text/plain'; + case '.json': + return 'application/json'; + case '.md': + case '.markdown': + return 'text/markdown'; + default: + return 'application/octet-stream'; + } +}; + +const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value); + +export const parseDroppedFileReference = (rawReference: string): + | { uri: vscode.Uri } + | { skipped: { name: string; reason: string } } => { + const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, ''); + if (!trimmed) { + return { skipped: { name: rawReference, reason: 'Empty drop reference' } }; + } + + if (hasUriScheme(trimmed)) { + try { + const parsed = vscode.Uri.parse(trimmed, true); + if (parsed.scheme !== 'file') { + return { + skipped: { + name: trimmed, + reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`, + }, + }; + } + return { uri: parsed }; + } catch (error) { + return { + skipped: { + name: trimmed, + reason: error instanceof Error ? error.message : 'Invalid URI', + }, + }; + } + } + + if (!path.isAbsolute(trimmed)) { + return { + skipped: { + name: trimmed, + reason: 'Drop reference is not an absolute file path', + }, + }; + } + + return { uri: vscode.Uri.file(trimmed) }; +}; + +export const readUriAsAttachment = async ( + uri: vscode.Uri, + fallbackName?: string, +): Promise< + | { file: { name: string; mimeType: string; size: number; dataUrl: string } } + | { skipped: { name: string; reason: string } } +> => { + const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file'); + + try { + const stat = await vscode.workspace.fs.stat(uri); + if ((stat.type & vscode.FileType.Directory) !== 0) { + return { skipped: { name, reason: 'Folders are not supported' } }; + } + + const size = stat.size ?? 0; + if (size > MAX_FILE_ATTACH_SIZE_BYTES) { + return { skipped: { name, reason: 'File exceeds 10MB limit' } }; + } + + const bytes = await vscode.workspace.fs.readFile(uri); + const ext = path.extname(name).toLowerCase(); + const mimeType = guessMimeTypeFromExtension(ext); + const base64 = Buffer.from(bytes).toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { file: { name, mimeType, size, dataUrl } }; + } catch (error) { + return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } }; + } +}; + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const normalizedCandidate = path.resolve(candidatePath); + const normalizedParent = path.resolve(parentPath); + return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); +}; + +export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); + +const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { + if (names.length === 0) { + return new Set(); + } + + const result = await execGit(['check-ignore', '--', ...names], cwd); + if (result.exitCode !== 0 || !result.stdout) { + return new Set(); + } + + return new Set( + result.stdout + .split('\n') + .map((name: string) => name.trim()) + .filter(Boolean), + ); +}; + +const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise> => { + if (paths.length === 0) { + return new Set(); + } + + const result = await execGit(['check-ignore', '--', ...paths], cwd); + if (result.exitCode !== 0 || !result.stdout) { + return new Set(); + } + + return new Set( + result.stdout + .split('\n') + .map((name: string) => name.trim()) + .filter(Boolean), + ); +}; + +const expandTildePath = (value: string) => { + const trimmed = (value || '').trim(); + if (!trimmed) { + return trimmed; + } + + if (trimmed === '~') { + return os.homedir(); + } + + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; +}; + +export const resolveUserPath = (value: string, baseDirectory: string) => { + const expanded = expandTildePath(value); + if (!expanded) { + return expanded; + } + if (path.isAbsolute(expanded)) { + return expanded; + } + return path.resolve(baseDirectory, expanded); +}; + +export const listDirectoryEntries = async (dirPath: string) => { + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + return entries.map(([name, fileType]) => ({ + name, + path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), + isDirectory: fileType === vscode.FileType.Directory, + })); +}; + +const FILE_SEARCH_EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + 'coverage', + 'tmp', + 'logs', +]); + +const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { + if (!name) { + return false; + } + if (!includeHidden && name.startsWith('.')) { + return true; + } + return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); +}; + +const fuzzyMatchScore = (query: string, candidate: string): number | null => { + if (!query) return 0; + + const q = query.toLowerCase(); + const c = candidate.toLowerCase(); + + if (c.includes(q)) { + const idx = c.indexOf(q); + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i++) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive++; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.min(gap, 10); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.floor(c.length / 3)); + + return score; +}; + +const searchFilesystemFiles = async ( + rootPath: string, + query: string, + limit: number, + includeHidden: boolean, + respectGitignore: boolean, + timeBudgetMs?: number, +) => { + const normalizedQuery = (query || '').trim().toLowerCase(); + const matchAll = normalizedQuery.length === 0; + const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null; + + const rootUri = vscode.Uri.file(rootPath); + const queue: vscode.Uri[] = [rootUri]; + const visited = new Set([normalizeFsPath(rootUri.fsPath)]); + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; + const MAX_CONCURRENCY = 10; + + while (queue.length > 0 && candidates.length < collectLimit) { + if (deadline && Date.now() > deadline) { + break; + } + const batch = queue.splice(0, MAX_CONCURRENCY); + const dirLists = await Promise.all( + batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])), + ); + + for (let index = 0; index < batch.length; index += 1) { + if (deadline && Date.now() > deadline) { + break; + } + const currentDir = batch[index]; + const dirents = dirLists[index]; + + const ignoredNames = respectGitignore + ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) + : new Set(); + + for (const [entryName, entryType] of dirents) { + if (!entryName || (!includeHidden && entryName.startsWith('.'))) { + continue; + } + + if (respectGitignore && ignoredNames.has(entryName)) { + continue; + } + + const entryUri = vscode.Uri.joinPath(currentDir, entryName); + const absolute = normalizeFsPath(entryUri.fsPath); + + if (entryType === vscode.FileType.Directory) { + if (shouldSkipSearchDirectory(entryName, includeHidden)) { + continue; + } + if (!visited.has(absolute)) { + visited.add(absolute); + queue.push(entryUri); + } + continue; + } + + if (entryType !== vscode.FileType.File) { + continue; + } + + const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score: 0, + }); + } else { + const score = fuzzyMatchScore(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score, + }); + } + } + + if (candidates.length >= collectLimit) { + queue.length = 0; + break; + } + } + + if (candidates.length >= collectLimit) { + break; + } + } + } + + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension, + })); +}; + +export const searchDirectory = async ( + directory: string, + query: string, + limit = 60, + includeHidden = false, + respectGitignore = true, +) => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const rootPath = directory + ? resolveUserPath(directory, workspaceRoot) + : vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (!rootPath) return []; + + const sanitizedQuery = query?.trim() || ''; + if (!sanitizedQuery) { + return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); + } + + const escapeGlob = (value: string) => value + .replace(/[\\{}()?*]/g, '\\$&') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]'); + const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; + const mapResults = (results: vscode.Uri[]) => results.map((file) => { + const absolute = normalizeFsPath(file.fsPath); + const relative = normalizeFsPath(path.relative(rootPath, absolute)); + const name = path.basename(absolute); + return { + name, + path: absolute, + relativePath: relative || name, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; + }); + const filterGitIgnored = async (results: vscode.Uri[]) => { + if (!respectGitignore || results.length === 0) { + return results; + } + + const relativePaths = results.map((file) => { + const relative = normalizeFsPath(path.relative(rootPath, file.fsPath)); + return relative || path.basename(file.fsPath); + }); + + const ignored = await gitCheckIgnorePaths(rootPath, relativePaths); + if (ignored.size === 0) { + return results; + } + + return results.filter((_, index) => !ignored.has(relativePaths[index])); + }; + + try { + const escapedQuery = escapeGlob(sanitizedQuery); + const pattern = `**/*${escapedQuery}*`; + const results = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), + exclude, + limit, + ); + + if (Array.isArray(results) && results.length > 0) { + const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.')); + const filtered = await filterGitIgnored(visible); + if (filtered.length > 0) { + return mapResults(filtered); + } + } + + if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) { + const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`; + const fuzzyResults = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern), + exclude, + limit, + ); + + if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) { + const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.')); + const filtered = await filterGitIgnored(visible); + if (filtered.length > 0) { + return mapResults(filtered); + } + } + } + } catch { + // Fall through to filesystem traversal. + } + + return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500); +}; + +export const fetchModelsMetadata = async () => { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; + try { + const response = await fetch('https://models.dev/api.json', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`models.dev responded with ${response.status}`); + } + return await response.json(); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + +export const getFsMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.markdown': 'text/markdown; charset=utf-8', + '.mmd': 'text/plain; charset=utf-8', + '.mermaid': 'text/plain; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.pdf': 'application/pdf', + }; + return mimeMap[ext] || 'application/octet-stream'; +}; + +export type FsReadPathResolution = + | { ok: true; resolvedPath: string } + | { ok: false; status: number; error: string }; + +export const resolveFileReadPath = async (targetPath: string): Promise => { + const trimmed = targetPath.trim(); + if (!trimmed) { + return { ok: false, status: 400, error: 'Path is required' }; + } + + const baseRoot = getFsAccessRoot(); + const resolved = resolveUserPath(trimmed, baseRoot); + if (!resolved) { + return { ok: false, status: 400, error: 'Path is required' }; + } + + try { + const [canonicalPath, canonicalBase] = await Promise.all([ + fs.promises.realpath(resolved), + fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)), + ]); + + if (!isPathInside(canonicalPath, canonicalBase)) { + return { ok: false, status: 403, error: 'Access to file denied' }; + } + + return { ok: true, resolvedPath: canonicalPath }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') { + return { ok: false, status: 404, error: 'File not found' }; + } + return { ok: false, status: 500, error: 'Failed to resolve file path' }; + } +}; diff --git a/packages/vscode/src/bridge-fs-runtime.ts b/packages/vscode/src/bridge-fs-runtime.ts new file mode 100644 index 00000000..fece387c --- /dev/null +++ b/packages/vscode/src/bridge-fs-runtime.ts @@ -0,0 +1,419 @@ +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type FsAttachment = { + name: string; + mimeType: string; + size: number; + dataUrl: string; +}; + +type SkippedAttachment = { + name: string; + reason: string; +}; + +type DroppedReferenceParse = + | { uri: vscode.Uri } + | { skipped: SkippedAttachment }; + +type ReadUriAsAttachmentResult = + | { file: FsAttachment } + | { skipped: SkippedAttachment }; + +type DirectoryEntry = { + name: string; + path: string; + isDirectory: boolean; +}; + +type FsDeps = { + resolveUserPath: (value: string, baseDirectory: string) => string; + listDirectoryEntries: (directoryPath: string) => Promise; + normalizeFsPath: (value: string) => string; + execGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>; + searchDirectory: ( + directory: string, + query: string, + limit: number | undefined, + includeHidden: boolean, + respectGitignore: boolean, + ) => Promise>; + resolveFileReadPath: (inputPath: string) => Promise< + | { ok: true; resolvedPath: string } + | { ok: false; status: number; error: string } + >; + parseDroppedFileReference: (rawReference: string) => DroppedReferenceParse; + readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise; +}; + +export async function handleFsBridgeMessage( + message: BridgeMessageInput, + deps: FsDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'files:list': { + const { path: dirPath } = payload as { path: string }; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(dirPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + const result = entries.map(([name, fileType]) => ({ + name, + path: vscode.Uri.joinPath(uri, name).fsPath, + isDirectory: fileType === vscode.FileType.Directory, + })); + return { id, type, success: true, data: { directory: deps.normalizeFsPath(resolvedPath), entries: result } }; + } + + case 'files:search': { + const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; + const pattern = `**/*${query}*`; + const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); + const results = files.map((file) => ({ + path: file.fsPath, + })); + return { id, type, success: true, data: results }; + } + + case 'workspace:folder': { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + return { id, type, success: true, data: { folder } }; + } + + case 'config:get': { + const { key } = payload as { key: string }; + const config = vscode.workspace.getConfiguration('openchamber'); + const value = config.get(key); + return { id, type, success: true, data: { value } }; + } + + case 'api:fs:list': { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; + const target = targetPath || workspaceRoot; + const resolvedPath = deps.resolveUserPath(target, workspaceRoot) || workspaceRoot; + + const entries = await deps.listDirectoryEntries(resolvedPath); + const normalized = deps.normalizeFsPath(resolvedPath); + + if (!respectGitignore) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + try { + const result = await deps.execGit(['check-ignore', '--', ...pathsToCheck], normalized); + const ignoredNames = new Set( + result.stdout + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); + return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; + } catch { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + } + + case 'api:fs:search': { + const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { + directory?: string; + query?: string; + limit?: number; + includeHidden?: boolean; + respectGitignore?: boolean; + }; + const files = await deps.searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); + return { id, type, success: true, data: { files } }; + } + + case 'api:fs:mkdir': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(target, workspaceRoot); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } }; + } + + case 'api:fs/home': { + return { id, type, success: true, data: { home: deps.normalizeFsPath(os.homedir()) } }; + } + + case 'api:fs:read': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + + const resolution = await deps.resolveFileReadPath(target); + if (!resolution.ok) { + return { id, type, success: false, error: resolution.error }; + } + + try { + const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); + return { id, type, success: true, data: { content, path: deps.normalizeFsPath(resolution.resolvedPath) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:write': { + const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; + if (!targetPath) { + return { id, type, success: false, error: 'Path is required' }; + } + if (typeof content !== 'string') { + return { id, type, success: false, error: 'Content is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + const parentUri = vscode.Uri.file(path.dirname(resolvedPath)); + try { + await vscode.workspace.fs.createDirectory(parentUri); + } catch { + // Directory may already exist + } + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to write file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:delete': { + const targetPath = (payload as { path: string })?.path; + if (!targetPath) { + return { id, type, success: false, error: 'Path is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); + return { id, type, success: true, data: { success: true } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:rename': { + const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {}; + if (!oldPath) { + return { id, type, success: false, error: 'oldPath is required' }; + } + if (!newPath) { + return { id, type, success: false, error: 'newPath is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedOld = deps.resolveUserPath(oldPath, workspaceRoot); + const resolvedNew = deps.resolveUserPath(newPath, workspaceRoot); + const oldUri = vscode.Uri.file(resolvedOld); + const newUri = vscode.Uri.file(resolvedNew); + await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false }); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedNew) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to rename file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:exec': { + const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {}; + if (!Array.isArray(commands) || commands.length === 0) { + return { id, type, success: false, error: 'Commands array is required' }; + } + if (!cwd) { + return { id, type, success: false, error: 'Working directory (cwd) is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedCwd = deps.resolveUserPath(cwd, workspaceRoot); + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + + const augmentedEnv = { + ...process.env, + PATH: process.env.PATH, + }; + + const results: Array<{ + command: string; + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; + }> = []; + + for (const cmd of commands) { + if (typeof cmd !== 'string' || !cmd.trim()) { + results.push({ command: cmd, success: false, error: 'Invalid command' }); + continue; + } + try { + const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, { + cwd: resolvedCwd, + env: augmentedEnv, + timeout: 300000, + }); + results.push({ + command: cmd, + success: true, + exitCode: 0, + stdout: (stdout || '').trim(), + stderr: (stderr || '').trim(), + }); + } catch (execError) { + const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string }; + results.push({ + command: cmd, + success: false, + exitCode: typeof err.code === 'number' ? err.code : 1, + stdout: (err.stdout || '').trim(), + stderr: (err.stderr || '').trim(), + error: err.message, + }); + } + } + + const allSucceeded = results.every((r) => r.success); + return { id, type, success: true, data: { success: allSucceeded, results } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to execute commands'; + return { id, type, success: false, error: message }; + } + } + + case 'api:files/pick': { + const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; + const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; + + const picks = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: allowMany, + defaultUri, + openLabel: 'Attach', + }); + + if (!picks || picks.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: FsAttachment[] = []; + const skipped: SkippedAttachment[] = []; + for (const uri of picks) { + const result = await deps.readUriAsAttachment(uri, path.basename(uri.fsPath || uri.path || uri.toString())); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:files/drop': { + const references = (payload as { uris?: string[] })?.uris; + const sourceUris = Array.isArray(references) ? references.filter((entry) => typeof entry === 'string') : []; + if (sourceUris.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: FsAttachment[] = []; + const skipped: SkippedAttachment[] = []; + const dedupedUris = Array.from(new Set(sourceUris.map((entry) => entry.trim()).filter(Boolean))); + + for (const rawUri of dedupedUris) { + const parsed = deps.parseDroppedFileReference(rawUri); + if ('skipped' in parsed) { + skipped.push(parsed.skipped); + continue; + } + + const uri = parsed.uri; + const name = path.basename(uri.fsPath || uri.path || rawUri); + + const result = await deps.readUriAsAttachment(uri, name); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:files/save-image': { + const rawFileName = (payload as { fileName?: unknown })?.fileName; + const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl; + const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : ''; + if (!dataUrl.startsWith('data:image/')) { + return { id, type, success: false, error: 'Invalid image payload' }; + } + + const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0 + ? rawFileName.trim() + : `message-${Date.now()}.png`; + + const saveUri = await vscode.window.showSaveDialog({ + saveLabel: 'Save image', + defaultUri: vscode.workspace.workspaceFolders?.[0] + ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName) + : undefined, + filters: { Images: ['png'] }, + }); + + if (!saveUri) { + return { id, type, success: true, data: { saved: false, canceled: true } }; + } + + const commaIndex = dataUrl.indexOf(','); + if (commaIndex === -1) { + return { id, type, success: false, error: 'Invalid image data URL' }; + } + + const base64 = dataUrl.slice(commaIndex + 1); + const bytes = Buffer.from(base64, 'base64'); + await vscode.workspace.fs.writeFile(saveUri, bytes); + + return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-git-process-runtime.ts b/packages/vscode/src/bridge-git-process-runtime.ts new file mode 100644 index 00000000..81d30522 --- /dev/null +++ b/packages/vscode/src/bridge-git-process-runtime.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; + +const isSocketPath = async (candidate: string): Promise => { + if (!candidate) { + return false; + } + try { + const stat = await fs.promises.stat(candidate); + return typeof stat.isSocket === 'function' && stat.isSocket(); + } catch { + return false; + } +}; + +const resolveSshAuthSock = async (): Promise => { + const existing = (process.env.SSH_AUTH_SOCK || '').trim(); + if (existing) { + return existing; + } + + if (process.platform === 'win32') { + return undefined; + } + + const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); + if (await isSocketPath(gpgSock)) { + return gpgSock; + } + + const runGpgconf = async (args: string[]): Promise => { + for (const candidate of gpgconfCandidates) { + try { + const { stdout } = await execFileAsync(candidate, args); + return String(stdout || ''); + } catch { + continue; + } + } + return ''; + }; + + const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); + if (candidate && await isSocketPath(candidate)) { + return candidate; + } + + if (candidate) { + await runGpgconf(['--launch', 'gpg-agent']); + const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); + if (retried && await isSocketPath(retried)) { + return retried; + } + } + + return undefined; +}; + +const buildGitEnv = async (): Promise => { + const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; + if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { + const resolved = await resolveSshAuthSock(); + if (resolved) { + env.SSH_AUTH_SOCK = resolved; + } + } + return env; +}; + +export const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => { + const env = await buildGitEnv(); + return new Promise((resolve) => { + const proc = spawn('git', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env, + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ stdout, stderr, exitCode: code ?? 0 }); + }); + + proc.on('error', (error) => { + resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); + }); + }); +}; diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts new file mode 100644 index 00000000..11c9322b --- /dev/null +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -0,0 +1,422 @@ +import * as gitService from './gitService'; +import type { BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +const requireDirectory = (id: string, type: string, directory?: string): BridgeResponse | null => { + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + return null; +}; + +export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:git/check': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const isRepo = await gitService.checkIsGitRepository(directory!); + return { id, type, success: true, data: isRepo }; + } + + case 'api:git/worktree-type': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const isLinked = await gitService.isLinkedWorktree(directory!); + return { id, type, success: true, data: isLinked }; + } + + case 'api:git/status': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const status = await gitService.getGitStatus(directory!); + return { id, type, success: true, data: status }; + } + + case 'api:git/branches': { + const { directory, method, name, startPoint, force } = (payload || {}) as { + directory?: string; + method?: string; + name?: string; + startPoint?: string; + force?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const branches = await gitService.getGitBranches(directory!); + return { id, type, success: true, data: branches }; + } + + if (normalizedMethod === 'POST') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.createBranch(directory!, name, startPoint); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.deleteGitBranch(directory!, name, force); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/remote-branches': { + const { directory, branch, remote } = (payload || {}) as { + directory?: string; + branch?: string; + remote?: string; + }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.deleteRemoteBranch(directory, branch, remote); + return { id, type, success: true, data: result }; + } + + case 'api:git/checkout': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.checkoutBranch(directory, branch); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees': { + const { directory, method } = (payload || {}) as { + directory?: string; + method?: string; + body?: unknown; + directoryPath?: string; + deleteLocalBranch?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const worktrees = await gitService.listGitWorktrees(directory!); + return { id, type, success: true, data: worktrees }; + } + + if (normalizedMethod === 'POST') { + const created = await gitService.createWorktree(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: created }; + } + + if (normalizedMethod === 'DELETE') { + const removePayload = payload as { + body?: { directory?: string; deleteLocalBranch?: boolean }; + directory?: string; + deleteLocalBranch?: boolean; + }; + const bodyDirectory = typeof removePayload?.body?.directory === 'string' + ? removePayload.body.directory + : ''; + const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : ''; + const worktreeDirectory = bodyDirectory || legacyDirectory || ''; + + if (!worktreeDirectory) { + return { id, type, success: false, error: 'Worktree directory is required' }; + } + const removed = await gitService.removeWorktree(directory!, { + directory: worktreeDirectory, + deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true, + }); + return { id, type, success: true, data: { success: Boolean(removed) } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/worktrees/validate': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.validateWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees/bootstrap-status': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.getWorktreeBootstrapStatus(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees/preview': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.previewWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: result }; + } + + case 'api:git/diff': { + const { directory, path: filePath, staged, contextLines } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + contextLines?: number; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); + return { id, type, success: true, data: result }; + } + + case 'api:git/file-diff': { + const { directory, path: filePath, staged } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitFileDiff(directory, filePath, staged); + return { id, type, success: true, data: result }; + } + + case 'api:git/revert': { + const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + await gitService.revertGitFile(directory, filePath); + return { id, type, success: true, data: { success: true } }; + } + + case 'api:git/commit': { + const { directory, message, addAll, files } = (payload || {}) as { + directory?: string; + message?: string; + addAll?: boolean; + files?: string[]; + }; + if (!directory || !message) { + return { id, type, success: false, error: 'Directory and message are required' }; + } + const result = await gitService.createGitCommit(directory, message, { addAll, files }); + return { id, type, success: true, data: result }; + } + + case 'api:git/push': { + const { directory, remote, branch, options } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + options?: string[] | Record; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitPush(directory!, { remote, branch, options }); + return { id, type, success: true, data: result }; + } + + case 'api:git/pull': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitPull(directory!, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/fetch': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitFetch(directory!, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/remotes': { + const { directory, method, remote } = (payload || {}) as { + directory?: string; + method?: string; + remote?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + if (normalizedMethod === 'GET') { + const result = await gitService.getRemotes(directory!); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!remote) { + return { id, type, success: false, error: 'Remote name is required' }; + } + const result = await gitService.removeRemote(directory!, remote); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/rebase': { + const { directory, onto } = (payload || {}) as { directory?: string; onto?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!onto) { + return { id, type, success: false, error: 'onto is required' }; + } + const result = await gitService.rebase(directory!, { onto }); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/abort': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.abortRebase(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!branch) { + return { id, type, success: false, error: 'branch is required' }; + } + const result = await gitService.merge(directory!, { branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/abort': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.abortMerge(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/continue': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.continueRebase(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/continue': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.continueMerge(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash': { + const { directory, message, includeUntracked } = (payload || {}) as { + directory?: string; + message?: string; + includeUntracked?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.stash(directory!, { message, includeUntracked }); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash/pop': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.stashPop(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/log': { + const { directory, maxCount, from, to, file } = (payload || {}) as { + directory?: string; + maxCount?: number; + from?: string; + to?: string; + file?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.getGitLog(directory!, { maxCount, from, to, file }); + return { id, type, success: true, data: result }; + } + + case 'api:git/commit-files': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + if (!directory || !hash) { + return { id, type, success: false, error: 'Directory and hash are required' }; + } + const result = await gitService.getCommitFiles(directory, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/identity': { + const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { + directory?: string; + method?: string; + userName?: string; + userEmail?: string; + sshKey?: string | null; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const identity = await gitService.getCurrentGitIdentity(directory!); + return { id, type, success: true, data: identity }; + } + + if (normalizedMethod === 'POST') { + if (!userName || !userEmail) { + return { id, type, success: false, error: 'userName and userEmail are required' }; + } + const result = await gitService.setGitIdentity(directory!, userName, userEmail, sshKey); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/ignore-openchamber': { + return { id, type, success: true, data: { success: true } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-git-special-runtime.ts b/packages/vscode/src/bridge-git-special-runtime.ts new file mode 100644 index 00000000..6fc60e51 --- /dev/null +++ b/packages/vscode/src/bridge-git-special-runtime.ts @@ -0,0 +1,455 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as gitService from './gitService'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ExecGitResult = { stdout: string; stderr: string; exitCode: number }; + +type SpecialGitDeps = { + readSettings: (ctx?: BridgeContext) => Record; + execGit: (args: string[], cwd: string) => Promise; +}; + +const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; +const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; +const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; +const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; + +let bridgeGitModelCatalogCache: Set | null = null; +let bridgeGitModelCatalogCacheAt = 0; + +const sleep = (ms: number) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +const readStringField = (value: unknown, key: string): string => { + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + const candidate = record[key]; + return typeof candidate === 'string' ? candidate.trim() : ''; +}; + +const fetchBridgeGitModelCatalog = async ( + apiUrl: string, + authHeaders?: Record +): Promise> => { + const now = Date.now(); + if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) { + return bridgeGitModelCatalogCache; + } + + const headers = authHeaders || {}; + const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); + const response = await fetch(modelsUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + throw new Error('Failed to fetch model catalog'); + } + + const payload = await response.json().catch(() => null) as unknown; + const refs = new Set(); + if (Array.isArray(payload)) { + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + const record = item as Record; + const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; + const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; + if (providerID && modelID) { + refs.add(`${providerID}/${modelID}`); + } + } + } + + bridgeGitModelCatalogCache = refs; + bridgeGitModelCatalogCacheAt = now; + return refs; +}; + +const resolveBridgeGitGenerationModel = async ( + payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, + settings: Record, + apiUrl: string, + authHeaders?: Record +): Promise<{ providerID: string; modelID: string }> => { + let catalog: Set | null = null; + try { + catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders); + } catch { + catalog = null; + } + + const hasModel = (providerID: string, modelID: string): boolean => { + if (!catalog) { + return false; + } + return catalog.has(`${providerID}/${modelID}`); + }; + + const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; + const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; + if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { + return { providerID: requestProviderId, modelID: requestModelId }; + } + + const settingsProviderId = readStringField(settings, 'gitProviderId'); + const settingsModelId = readStringField(settings, 'gitModelId'); + if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { + return { providerID: settingsProviderId, modelID: settingsModelId }; + } + + const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; + const settingsZenModel = readStringField(settings, 'zenModel'); + return { + providerID: 'zen', + modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, + }; +}; + +const extractTextFromMessageParts = (parts: unknown): string => { + if (!Array.isArray(parts)) { + return ''; + } + + const textParts = parts + .filter((part) => { + if (!part || typeof part !== 'object') return false; + const record = part as Record; + return record.type === 'text' && typeof record.text === 'string'; + }) + .map((part) => (part as Record).text as string) + .map((text) => text.trim()) + .filter((text) => text.length > 0); + + return textParts.join('\n').trim(); +}; + +const generateBridgeTextWithSessionFlow = async ({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders, +}: { + apiUrl: string; + directory: string; + prompt: string; + providerID: string; + modelID: string; + authHeaders?: Record; +}): Promise => { + const headers = authHeaders || {}; + const apiBase = apiUrl.replace(/\/+$/, ''); + const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; + const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); + let sessionId: string | null = null; + + try { + const sessionUrl = new URL(`${apiBase}/session`); + if (directory) { + sessionUrl.searchParams.set('directory', directory); + } + + const createResponse = await fetch(sessionUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ title: 'Git Generation' }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!createResponse.ok) { + throw new Error('Failed to create OpenCode session'); + } + + const session = await createResponse.json().catch(() => null) as unknown; + const sessionObj = session && typeof session === 'object' ? session as Record : null; + const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; + if (!createdSessionId) { + throw new Error('Invalid session response'); + } + sessionId = createdSessionId; + + const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); + if (directory) { + promptUrl.searchParams.set('directory', directory); + } + + const promptResponse = await fetch(promptUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ + model: { + providerID, + modelID, + }, + parts: [{ type: 'text', text: prompt }], + }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!promptResponse.ok) { + throw new Error('Failed to send prompt'); + } + + const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); + if (directory) { + messagesUrl.searchParams.set('directory', directory); + } + messagesUrl.searchParams.set('limit', '10'); + + while (Date.now() < deadlineAt) { + await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); + + const messagesResponse = await fetch(messagesUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!messagesResponse.ok) { + continue; + } + + const messages = await messagesResponse.json().catch(() => null) as unknown; + if (!Array.isArray(messages)) { + continue; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] as Record | null; + if (!message || typeof message !== 'object') { + continue; + } + const info = message.info as Record | undefined; + if (info?.role !== 'assistant' || info?.finish !== 'stop') { + continue; + } + + const text = extractTextFromMessageParts(message.parts); + if (text) { + return text; + } + } + } + + throw new Error('Timeout waiting for generation to complete'); + } finally { + if (sessionId) { + const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); + try { + await fetch(deleteUrl.toString(), { + method: 'DELETE', + headers, + signal: AbortSignal.timeout(5_000), + }); + } catch { + // ignore cleanup failures + } + } + } +}; + +const parseJsonObjectSafe = (value: string): Record | null => { + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +}; + +export async function handleSpecialGitBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: SpecialGitDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:git/pr-description': { + const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as { + directory?: string; + base?: string; + head?: string; + context?: string; + providerId?: string; + modelId?: string; + zenModel?: string; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + if (!base || !head) { + return { id, type, success: false, error: 'base and head are required' }; + } + + let files: string[] = []; + try { + const listed = await gitService.getGitRangeFiles(directory, base, head); + files = Array.isArray(listed) ? listed : []; + } catch { + files = []; + } + + if (files.length === 0) { + return { id, type, success: false, error: 'No diffs available for base...head' }; + } + + let diffSummaries = ''; + for (const file of files) { + try { + const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3); + const raw = typeof diff?.diff === 'string' ? diff.diff : ''; + if (!raw.trim()) continue; + diffSummaries += `FILE: ${file}\n${raw}\n\n`; + } catch { + // ignore + } + } + + if (!diffSummaries.trim()) { + return { id, type, success: false, error: 'No diffs available for selected files' }; + } + + const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`; + + try { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return { id, type, success: false, error: 'OpenCode API unavailable' }; + } + + const settings = deps.readSettings(ctx) as Record; + const { providerID, modelID } = await resolveBridgeGitGenerationModel( + { providerId, modelId, zenModel: payloadZenModel }, + settings, + apiUrl, + ctx?.manager?.getOpenCodeAuthHeaders() + ); + const raw = await generateBridgeTextWithSessionFlow({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(), + }); + if (!raw) { + return { id, type, success: false, error: 'No PR description returned by generator' }; + } + + const cleaned = String(raw) + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw); + if (parsed) { + const title = typeof parsed.title === 'string' ? parsed.title : ''; + const body = typeof parsed.body === 'string' ? parsed.body : ''; + return { id, type, success: true, data: { title, body } }; + } + + return { id, type, success: true, data: { title: '', body: String(raw) } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + + case 'api:git/conflict-details': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + try { + const statusResult = await deps.execGit(['status', '--porcelain'], directory); + const statusPorcelain = statusResult.stdout; + + const unmergedResult = await deps.execGit(['diff', '--name-only', '--diff-filter=U'], directory); + const unmergedFiles = unmergedResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const diffResult = await deps.execGit(['diff'], directory); + const diff = diffResult.stdout; + + let operation: 'merge' | 'rebase' = 'merge'; + let headInfo = ''; + + const mergeHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory); + const mergeHeadExists = mergeHeadResult.exitCode === 0; + + if (mergeHeadExists) { + operation = 'merge'; + const mergeHead = mergeHeadResult.stdout.trim(); + let mergeMsg = ''; + try { + const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG'); + mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8'); + } catch { + // MERGE_MSG may not exist + } + headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`; + } else { + const rebaseHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory); + const rebaseHeadExists = rebaseHeadResult.exitCode === 0; + + if (rebaseHeadExists) { + operation = 'rebase'; + const rebaseHead = rebaseHeadResult.stdout.trim(); + headInfo = `REBASE_HEAD: ${rebaseHead}`; + } + } + + return { + id, + type, + success: true, + data: { + statusPorcelain: statusPorcelain.trim(), + unmergedFiles, + diff: diff.trim(), + headInfo: headInfo.trim(), + operation, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts new file mode 100644 index 00000000..66618ffb --- /dev/null +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -0,0 +1,99 @@ +import * as fs from 'fs'; +import { getFsMimeType, resolveFileReadPath, type FsReadPathResolution } from './bridge-fs-helpers-runtime'; + +type ApiProxyResponsePayload = { + status: number; + headers: Record; + bodyBase64: string; +}; + +export const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); + +export const collectHeaders = (headers: Headers): Record => { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +}; + +export const buildUnavailableApiResponse = (): ApiProxyResponsePayload => { + const body = JSON.stringify({ error: 'OpenCode API unavailable' }); + return { + status: 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; +}; + +export const sanitizeForwardHeaders = (input: Record | undefined): Record => { + const headers: Record = { ...(input || {}) }; + delete headers['content-length']; + delete headers['host']; + delete headers['connection']; + return headers; +}; + +const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePayload => ({ + status, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(JSON.stringify({ error })), +}); + +export const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise => { + let parsed: URL; + try { + parsed = new URL(requestPath, 'https://openchamber.local'); + } catch { + return buildProxyJsonError(400, 'Invalid request path'); + } + + if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { + return null; + } + + if (method !== 'GET' && method !== 'HEAD') { + return buildProxyJsonError(405, 'Method not allowed'); + } + + const targetPath = parsed.searchParams.get('path') || ''; + const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath); + if (!resolution.ok) { + return buildProxyJsonError(resolution.status, resolution.error); + } + + try { + const stats = await fs.promises.stat(resolution.resolvedPath); + if (!stats.isFile()) { + return buildProxyJsonError(400, 'Specified path is not a file'); + } + + if (parsed.pathname === '/api/fs/read') { + const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); + return { + status: 200, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }, + bodyBase64: base64EncodeUtf8(content), + }; + } + + const raw = await fs.promises.readFile(resolution.resolvedPath); + return { + status: 200, + headers: { + 'content-type': getFsMimeType(resolution.resolvedPath), + 'cache-control': 'no-store', + }, + bodyBase64: Buffer.from(raw).toString('base64'), + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') { + return buildProxyJsonError(404, 'File not found'); + } + return buildProxyJsonError(500, 'Unable to read file'); + } +}; diff --git a/packages/vscode/src/bridge-proxy-runtime.ts b/packages/vscode/src/bridge-proxy-runtime.ts new file mode 100644 index 00000000..6de2808a --- /dev/null +++ b/packages/vscode/src/bridge-proxy-runtime.ts @@ -0,0 +1,180 @@ +import type { BridgeContext, BridgeResponse } from './bridge'; +import { waitForApiUrl } from './opencode-ready'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ApiProxyRequestPayload = { + method?: string; + path?: string; + headers?: Record; + bodyBase64?: string; +}; + +type ApiSessionMessageRequestPayload = { + path?: string; + headers?: Record; + bodyText?: string; +}; + +type ApiProxyResponsePayload = { + status: number; + headers: Record; + bodyBase64: string; +}; + +type ProxyRuntimeDeps = { + tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise; + buildUnavailableApiResponse: () => ApiProxyResponsePayload; + sanitizeForwardHeaders: (input: Record | undefined) => Record; + collectHeaders: (headers: Headers) => Record; + base64EncodeUtf8: (text: string) => string; +}; + +export async function handleProxyBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: ProxyRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:proxy': { + const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath); + if (localFsResponse) { + return { id, type, success: true, data: localFsResponse }; + } + + const apiUrl = await waitForApiUrl(ctx?.manager); + if (!apiUrl) { + const data = deps.buildUnavailableApiResponse(); + return { id, type, success: true, data }; + } + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = { + ...deps.sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; + + if (normalizedPath === '/event' || normalizedPath === '/global/event') { + if (!requestHeaders.Accept) { + requestHeaders.Accept = 'text/event-stream'; + } + requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; + requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; + } + + try { + const response = await fetch(targetUrl, { + method: normalizedMethod, + headers: requestHeaders, + body: + typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' + ? Buffer.from(bodyBase64, 'base64') + : undefined, + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: deps.collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const body = JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', + }); + const data: ApiProxyResponsePayload = { + status: 502, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + + case 'api:session:message': { + const apiUrl = await waitForApiUrl(ctx?.manager); + if (!apiUrl) { + const data = deps.buildUnavailableApiResponse(); + return { id, type, success: true, data }; + } + + const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) { + const body = JSON.stringify({ error: 'Invalid session message proxy path' }); + const data: ApiProxyResponsePayload = { + status: 400, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = { + ...deps.sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; + + try { + const response = await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: typeof bodyText === 'string' ? bodyText : '', + signal: AbortSignal.timeout(45000), + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: deps.collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const isTimeout = + error instanceof Error && + ((error as Error & { name?: string }).name === 'TimeoutError' || + (error as Error & { name?: string }).name === 'AbortError'); + const body = JSON.stringify({ + error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed', + }); + const data: ApiProxyResponsePayload = { + status: isTimeout ? 504 : 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts new file mode 100644 index 00000000..cc01948c --- /dev/null +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -0,0 +1,231 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; +import type { BridgeContext } from './bridge'; + +const SETTINGS_KEY = 'openchamber.settings'; +const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const relative = path.relative(parentPath, candidatePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +}; + +const findWorktreeRootForSkills = (workingDirectory?: string): string | null => { + if (!workingDirectory) return null; + let current = path.resolve(workingDirectory); + while (true) { + const gitPath = path.join(current, '.git'); + try { + const stat = fs.statSync(gitPath); + if (stat.isFile()) { + return current; + } + } catch { + // Continue climbing. + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const getProjectAncestors = (workingDirectory?: string): string[] => { + if (!workingDirectory) return []; + const result: string[] = []; + let current = path.resolve(workingDirectory); + const stop = findWorktreeRootForSkills(workingDirectory) || current; + while (true) { + result.push(current); + if (current === stop) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return result; +}; + +const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => { + const resolvedPath = path.resolve(location); + const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) + ? 'agents' + : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) + ? 'claude' + : 'opencode'; + + const projectAncestors = getProjectAncestors(workingDirectory); + const isProjectScoped = projectAncestors.some((ancestor) => { + const candidates = [ + path.join(ancestor, '.opencode'), + path.join(ancestor, '.claude', 'skills'), + path.join(ancestor, '.agents', 'skills'), + ]; + return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); + }); + + if (isProjectScoped) { + return { scope: 'project', source }; + } + + const home = os.homedir(); + const userRoots = [ + path.join(home, '.config', 'opencode'), + path.join(home, '.opencode'), + path.join(home, '.claude', 'skills'), + path.join(home, '.agents', 'skills'), + process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, + ].filter((value): value is string => Boolean(value)); + + if (userRoots.some((root) => isPathInside(resolvedPath, root))) { + return { scope: 'user', source }; + } + + return { scope: 'user', source }; +}; + +export const fetchOpenCodeSkillsFromApi = async ( + ctx: BridgeContext | undefined, + workingDirectory?: string, +): Promise => { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return null; + } + + try { + const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; + const url = new URL('skill', base); + if (workingDirectory) { + url.searchParams.set('directory', workingDirectory); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...(ctx?.manager?.getOpenCodeAuthHeaders() || {}), + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + return null; + } + + return payload + .map((item) => { + const name = typeof item?.name === 'string' ? item.name.trim() : ''; + const location = typeof item?.location === 'string' ? item.location : ''; + const description = typeof item?.description === 'string' ? item.description : ''; + if (!name || !location) { + return null; + } + const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory); + return { + name, + path: location, + scope: inferred.scope, + source: inferred.source, + description, + } as DiscoveredSkill; + }) + .filter((item): item is DiscoveredSkill => item !== null); + } catch { + return null; + } +}; + +const readSharedSettingsFromDisk = (): Record => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +}; + +const writeSharedSettingsToDisk = async (changes: Record): Promise => { + try { + await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); + const current = readSharedSettingsFromDisk(); + const next: Record = { ...current, ...changes }; + await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8'); + } catch { + // ignore + } +}; + +export const readSettings = (ctx?: BridgeContext): Record => { + const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; + const restStored = { ...stored }; + delete (restStored as Record).lastDirectory; + const shared = readSharedSettingsFromDisk(); + const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : ''; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const themeVariant = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight + ? 'light' + : 'dark'; + + return { + themeVariant, + lastDirectory: workspaceFolder, + ...restStored, + opencodeBinary: + typeof restStored.opencodeBinary === 'string' + ? String(restStored.opencodeBinary).trim() + : (sharedOpencodeBinary || undefined), + }; +}; + +export const persistSettings = async (changes: Record, ctx?: BridgeContext): Promise> => { + const current = readSettings(ctx); + const restChanges = { ...(changes || {}) }; + delete restChanges.lastDirectory; + + const keysToClear = new Set(); + + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { + const value = restChanges[key]; + if (typeof value === 'string' && value.trim().length === 0) { + keysToClear.add(key); + delete restChanges[key]; + } + } + + if (typeof restChanges.usageAutoRefresh !== 'boolean') { + delete restChanges.usageAutoRefresh; + } + + if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) { + restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs))); + } else { + delete restChanges.usageRefreshIntervalMs; + } + + const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record; + for (const key of keysToClear) { + delete merged[key]; + } + await ctx?.context?.globalState.update(SETTINGS_KEY, merged); + + if (keysToClear.has('opencodeBinary')) { + await writeSharedSettingsToDisk({ opencodeBinary: '' }); + } else if (typeof restChanges.opencodeBinary === 'string') { + await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() }); + } + + return merged; +}; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts new file mode 100644 index 00000000..c50d9be7 --- /dev/null +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -0,0 +1,558 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { randomUUID } from 'crypto'; +import { removeProviderConfig, getProviderSources } from './opencodeConfig'; +import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; +import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; +import { getSessionActivitySnapshot } from './sessionActivityWatcher'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type SystemRuntimeDeps = { + resolveUserPath: (value: string, baseDirectory: string) => string; + fetchModelsMetadata: () => Promise; + updateCheckUrl: string; + clientReloadDelayMs: number; +}; + +type NotificationBridgePayload = { + title?: string; + body?: string; + tag?: string; +}; + +type NotificationsNotifyRequestPayload = { + payload?: NotificationBridgePayload; +}; + +const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; +const ZEN_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; +let cachedZenModels: { models: Array<{ id: string; owned_by?: string }>; at: number } | null = null; + +const getOpenChamberConfigDir = (): string => { + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (appData) return path.join(appData, 'openchamber'); + } + return path.join(os.homedir(), '.config', 'openchamber'); +}; + +const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { + if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; + return 'web'; +}; + +const getOrCreateInstallId = (scope: string): string => { + const configDir = getOpenChamberConfigDir(); + const normalizedScope = sanitizeInstallScope(scope); + const idPath = path.join(configDir, `install-id-${normalizedScope}`); + + try { + const existing = fs.readFileSync(idPath, 'utf8').trim(); + if (existing) return existing; + } catch { + // Generate new id. + } + + const installId = randomUUID(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); + return installId; +}; + +const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { + if (value === 'darwin') return 'macos'; + if (value === 'win32') return 'windows'; + if (value === 'linux') return 'linux'; + return 'web'; +}; + +const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { + if (value === 'arm64' || value === 'aarch64') return 'arm64'; + if (value === 'x64' || value === 'amd64') return 'x64'; + return 'unknown'; +}; + +type ParsedDiffHunk = { + newStart: number; + oldLines: string[]; + newLines: string[]; +}; + +const VIRTUAL_DIFF_SCHEME = 'openchamber-diff'; +const virtualDiffContents = new Map(); +let virtualDiffCounter = 0; +let virtualDiffProviderDisposable: vscode.Disposable | null = null; + +const asObject = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + +const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => { + if (virtualDiffProviderDisposable) { + return; + } + + virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider( + VIRTUAL_DIFF_SCHEME, + { + provideTextDocumentContent: (uri: vscode.Uri) => { + const key = new URLSearchParams(uri.query).get('key') || ''; + return virtualDiffContents.get(key) ?? ''; + }, + }, + ); + + if (ctx?.context) { + ctx.context.subscriptions.push(virtualDiffProviderDisposable); + } +}; + +const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => { + const key = `${Date.now()}-${++virtualDiffCounter}`; + virtualDiffContents.set(key, content); + + if (virtualDiffContents.size > 100) { + const firstKey = virtualDiffContents.keys().next().value; + if (firstKey) { + virtualDiffContents.delete(firstKey); + } + } + + return vscode.Uri.from({ + scheme: VIRTUAL_DIFF_SCHEME, + path: `/${path.basename(modifiedPath) || 'original'}`, + query: `key=${encodeURIComponent(key)}`, + }); +}; + +const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => { + const lines = patch.split(/\r?\n/); + const hunks: ParsedDiffHunk[] = []; + + let current: ParsedDiffHunk | null = null; + + for (const line of lines) { + const headerMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (headerMatch) { + if (current) { + hunks.push(current); + } + current = { + newStart: Number(headerMatch[1] || 1), + oldLines: [], + newLines: [], + }; + continue; + } + + if (!current) continue; + + if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('\\ No newline')) { + continue; + } + + if (line.startsWith('-')) { + current.oldLines.push(line.slice(1)); + continue; + } + + if (line.startsWith('+')) { + current.newLines.push(line.slice(1)); + continue; + } + + if (line.startsWith(' ')) { + const content = line.slice(1); + current.oldLines.push(content); + current.newLines.push(content); + } + } + + if (current) { + hunks.push(current); + } + + return hunks; +}; + +const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => { + const hunks = parseUnifiedDiffHunks(patch); + if (hunks.length === 0) { + return null; + } + + const lines = modifiedContent.split('\n'); + for (let index = hunks.length - 1; index >= 0; index -= 1) { + const hunk = hunks[index]; + if (!hunk) { + continue; + } + const startIndex = Math.max(0, hunk.newStart - 1); + const replaceCount = hunk.newLines.length; + lines.splice(startIndex, replaceCount, ...hunk.oldLines); + } + + return lines.join('\n'); +}; + +const fetchFreeZenModels = async (): Promise> => { + const now = Date.now(); + if (cachedZenModels && now - cachedZenModels.at < ZEN_MODELS_CACHE_TTL_MS) { + return cachedZenModels.models; + } + + const response = await fetch(ZEN_MODELS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + throw new Error(`zen models request failed (${response.status})`); + } + + const rawPayload = await response.json().catch(() => null); + const payload = asObject(rawPayload); + const rows = Array.isArray(payload?.data) ? payload.data : []; + const models = rows + .map((entry) => { + const id = typeof (entry as { id?: unknown })?.id === 'string' + ? (entry as { id: string }).id.trim() + : ''; + const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string' + ? (entry as { owned_by: string }).owned_by + : undefined; + if (!id || !id.endsWith('-free')) return null; + return ownedBy ? { id, owned_by: ownedBy } : { id }; + }) + .filter((entry): entry is { id: string; owned_by?: string } => entry !== null); + + cachedZenModels = { models, at: Date.now() }; + return models; +}; + +export async function handleSystemBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: SystemRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:opencode/directory': { + const target = (payload as { path?: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + const baseDirectory = + ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(target, baseDirectory); + const result = await ctx?.manager?.setWorkingDirectory(resolvedPath); + if (!result) { + return { id, type, success: false, error: 'OpenCode manager unavailable' }; + } + return { id, type, success: true, data: result }; + } + + case 'api:models/metadata': { + try { + const data = await deps.fetchModelsMetadata(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:session-activity:get': { + return { id, type, success: true, data: getSessionActivitySnapshot() }; + } + + case 'api:zen:models': { + try { + const models = await fetchFreeZenModels(); + return { id, type, success: true, data: { models } }; + } catch (error) { + if (cachedZenModels) { + return { id, type, success: true, data: { models: cachedZenModels.models } }; + } + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:openchamber:update-check': { + try { + const body = (payload && typeof payload === 'object' ? payload : {}) as Record; + const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0 + ? body.currentVersion.trim() + : 'unknown'; + const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0 + ? body.instanceMode.trim() + : 'local'; + const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 + ? body.deviceClass.trim() + : 'desktop'; + const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0 + ? body.platform.trim() + : os.platform(); + const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0 + ? body.arch.trim() + : os.arch(); + const reportUsage = body.reportUsage !== false; + + const installId = getOrCreateInstallId('vscode'); + const requestBody = { + appType: 'vscode', + deviceClass, + platform: mapNodePlatformToApiPlatform(platformRaw), + arch: mapNodeArchToApiArch(archRaw), + channel: 'stable', + currentVersion, + installId, + instanceMode, + reportUsage, + }; + + const response = await fetch(deps.updateCheckUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'update check failed'); + return { id, type, success: false, error: text || `Update check failed with ${response.status}` }; + } + + const data = await response.json(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openFile': { + const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; + try { + const doc = await vscode.workspace.openTextDocument(filePath); + const options: vscode.TextDocumentShowOptions = {}; + if (typeof line === 'number') { + const pos = new vscode.Position(Math.max(0, line - 1), column || 0); + options.selection = new vscode.Range(pos, pos); + } + await vscode.window.showTextDocument(doc, options); + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openDiff': { + const { original, modified, label, line, patch } = payload as { + original: string; + modified: string; + label?: string; + line?: number; + patch?: string; + }; + try { + const modifiedUri = vscode.Uri.file(modified); + const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri); + let originalUri = original ? vscode.Uri.file(original) : modifiedUri; + + if (typeof patch === 'string' && patch.trim().length > 0) { + const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch); + if (typeof originalContent === 'string') { + ensureVirtualDiffProviderRegistered(ctx); + originalUri = createVirtualOriginalDiffUri(modified, originalContent); + } + } + + const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`; + const title = label || `${leftLabel} ↔ ${path.basename(modified)}`; + + await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); + + if (typeof line === 'number' && Number.isFinite(line)) { + const targetLine = Math.max(0, Math.trunc(line) - 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + const targetEditor = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.toString() === modifiedUri.toString(), + ); + if (targetEditor) { + const target = new vscode.Position(targetLine, 0); + targetEditor.selection = new vscode.Selection(target, target); + targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter); + } + } + + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:provider/auth:delete': { + const { providerId, scope, directory } = (payload || {}) as { providerId?: string; scope?: string; directory?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + const normalizedScope = typeof scope === 'string' ? scope : 'auth'; + const workingDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim() + : ctx?.manager?.getWorkingDirectory(); + try { + let removed = false; + if (normalizedScope === 'auth') { + removed = removeProviderAuth(providerId); + } else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') { + removed = removeProviderConfig(providerId, workingDirectory, normalizedScope); + } else if (normalizedScope === 'all') { + const authRemoved = removeProviderAuth(providerId); + const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user'); + const projectRemoved = workingDirectory + ? removeProviderConfig(providerId, workingDirectory, 'project') + : false; + const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom'); + removed = authRemoved || userRemoved || projectRemoved || customRemoved; + } else { + return { id, type, success: false, error: 'Invalid scope' }; + } + + if (removed) { + await ctx?.manager?.restart(); + } + return { + id, + type, + success: true, + data: { + success: true, + removed, + requiresReload: removed, + message: removed + ? `Provider ${providerId} disconnected successfully. Reloading interface…` + : `Provider ${providerId} was not configured.`, + reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:provider/source:get': { + const { providerId, directory } = (payload || {}) as { providerId?: string; directory?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + try { + const workingDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim() + : ctx?.manager?.getWorkingDirectory(); + const sources = getProviderSources(providerId, workingDirectory); + const auth = getProviderAuth(providerId); + sources.auth.exists = Boolean(auth); + return { id, type, success: true, data: { providerId, sources } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:quota:providers': { + try { + const providers = listConfiguredQuotaProviders(); + return { id, type, success: true, data: { providers } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:quota:get': { + const { providerId } = (payload || {}) as { providerId?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + try { + const result = await fetchQuotaForProvider(providerId); + return { id, type, success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'vscode:command': { + const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; + if (!command) { + return { id, type, success: false, error: 'Command is required' }; + } + try { + const result = await vscode.commands.executeCommand(command, ...(args || [])); + return { id, type, success: true, data: { result } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'vscode:openExternalUrl': { + const { url } = (payload || {}) as { url?: string }; + const target = typeof url === 'string' ? url.trim() : ''; + if (!target) { + return { id, type, success: false, error: 'URL is required' }; + } + try { + await vscode.env.openExternal(vscode.Uri.parse(target)); + return { id, type, success: true, data: { opened: true } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'notifications:can-notify': { + return { id, type, success: true, data: true }; + } + + case 'notifications:notify': { + const request = (payload || {}) as NotificationsNotifyRequestPayload; + const notification = request.payload || {}; + const title = typeof notification.title === 'string' ? notification.title.trim() : ''; + const body = typeof notification.body === 'string' ? notification.body.trim() : ''; + + const message = title && body + ? `${title}: ${body}` + : title || body; + + if (!message) { + return { id, type, success: true, data: { shown: false } }; + } + + void vscode.window.showInformationMessage(message); + return { id, type, success: true, data: { shown: true } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 03ecc34b..c72ef9f1 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -1,51 +1,30 @@ import * as vscode from 'vscode'; -import * as os from 'os'; -import * as path from 'path'; -import * as fs from 'fs'; -import { randomUUID } from 'crypto'; -import { spawn, execFile } from 'child_process'; -import { promisify } from 'util'; import { type OpenCodeManager } from './opencode'; -import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig, listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './opencodeConfig'; -import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; -import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; -import * as gitService from './gitService'; +import { handleStandardGitBridgeMessage } from './bridge-git-runtime'; +import { handleSpecialGitBridgeMessage } from './bridge-git-special-runtime'; +import { handleFsBridgeMessage } from './bridge-fs-runtime'; +import { handleConfigBridgeMessage } from './bridge-config-runtime'; +import { handleSystemBridgeMessage } from './bridge-system-runtime'; +import { handleProxyBridgeMessage } from './bridge-proxy-runtime'; +import { fetchOpenCodeSkillsFromApi, persistSettings, readSettings } from './bridge-settings-runtime'; +import { execGit } from './bridge-git-process-runtime'; import { - getSkillsCatalog, - scanSkillsRepository as scanSkillsRepositoryFromGit, - installSkillsFromRepository as installSkillsFromGit, - type SkillsCatalogSourceConfig, -} from './skillsCatalog'; + parseDroppedFileReference, + readUriAsAttachment, + resolveUserPath, + listDirectoryEntries, + normalizeFsPath, + searchDirectory, + resolveFileReadPath, + fetchModelsMetadata, +} from './bridge-fs-helpers-runtime'; import { - DEFAULT_GITHUB_CLIENT_ID, - DEFAULT_GITHUB_SCOPES, - activateGitHubAuth, - clearGitHubAuth, - exchangeDeviceCode, - fetchMe, - readGitHubAuth, - readGitHubAuthList, - startDeviceFlow, - writeGitHubAuth, -} from './githubAuth'; -import { - createPullRequest, - getPullRequestStatus, - markPullRequestReady, - mergePullRequest, - updatePullRequest, -} from './githubPr'; - -import { - getIssue, - listIssueComments, - listIssues, -} from './githubIssues'; - -import { - getPullRequestContext, - listPullRequests, -} from './githubPulls'; + tryHandleLocalFsProxy, + buildUnavailableApiResponse, + sanitizeForwardHeaders, + collectHeaders, + base64EncodeUtf8, +} from './bridge-localfs-proxy-runtime'; export interface BridgeRequest { id: string; @@ -61,3762 +40,108 @@ export interface BridgeResponse { error?: string; } -type ApiProxyRequestPayload = { - method?: string; - path?: string; - headers?: Record; - bodyBase64?: string; -}; - -type ApiSessionMessageRequestPayload = { - path?: string; - headers?: Record; - bodyText?: string; -}; - -type ApiProxyResponsePayload = { - status: number; - headers: Record; - bodyBase64: string; -}; - -type NotificationBridgePayload = { - title?: string; - body?: string; - tag?: string; -}; - -type NotificationsNotifyRequestPayload = { - payload?: NotificationBridgePayload; -}; - -interface FileEntry { - name: string; - path: string; - isDirectory: boolean; -} - -interface FileSearchResult { - path: string; - score?: number; -} - export interface BridgeContext { manager?: OpenCodeManager; context?: vscode.ExtensionContext; } -const SETTINGS_KEY = 'openchamber.settings'; const CLIENT_RELOAD_DELAY_MS = 800; -const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; -const execFileAsync = promisify(execFile); -const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; -const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; +const GITHUB_BACKEND_DISABLED_ERROR = 'OpenChamber VS Code backend GitHub integration is disabled. Use native VS Code GitHub integrations.'; -const getOpenChamberConfigDir = (): string => { - if (process.platform === 'win32') { - const appData = process.env.APPDATA; - if (appData) return path.join(appData, 'openchamber'); - } - return path.join(os.homedir(), '.config', 'openchamber'); -}; - -const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { - if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; - return 'web'; -}; - -const getOrCreateInstallId = (scope: string): string => { - const configDir = getOpenChamberConfigDir(); - const normalizedScope = sanitizeInstallScope(scope); - const idPath = path.join(configDir, `install-id-${normalizedScope}`); - - try { - const existing = fs.readFileSync(idPath, 'utf8').trim(); - if (existing) return existing; - } catch { - // Generate new id. - } - - const installId = randomUUID(); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); - return installId; -}; - -const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { - if (value === 'darwin') return 'macos'; - if (value === 'win32') return 'windows'; - if (value === 'linux') return 'linux'; - return 'web'; -}; - -const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { - if (value === 'arm64' || value === 'aarch64') return 'arm64'; - if (value === 'x64' || value === 'amd64') return 'x64'; - return 'unknown'; -}; - -const guessMimeTypeFromExtension = (ext: string) => { - switch (ext) { - case '.png': - case '.jpg': - case '.jpeg': - case '.gif': - case '.bmp': - case '.webp': - return `image/${ext.replace('.', '')}`; - case '.pdf': - return 'application/pdf'; - case '.txt': - case '.log': - return 'text/plain'; - case '.json': - return 'application/json'; - case '.md': - case '.markdown': - return 'text/markdown'; - default: - return 'application/octet-stream'; - } -}; - -const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value); - -const parseDroppedFileReference = (rawReference: string): - | { uri: vscode.Uri } - | { skipped: { name: string; reason: string } } => { - const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, ''); - if (!trimmed) { - return { skipped: { name: rawReference, reason: 'Empty drop reference' } }; - } - - if (hasUriScheme(trimmed)) { - try { - const parsed = vscode.Uri.parse(trimmed, true); - if (parsed.scheme !== 'file') { - return { - skipped: { - name: trimmed, - reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`, - }, - }; - } - return { uri: parsed }; - } catch (error) { - return { - skipped: { - name: trimmed, - reason: error instanceof Error ? error.message : 'Invalid URI', - }, - }; - } - } - - if (!path.isAbsolute(trimmed)) { - return { - skipped: { - name: trimmed, - reason: 'Drop reference is not an absolute file path', - }, - }; - } - - return { uri: vscode.Uri.file(trimmed) }; -}; - -const readUriAsAttachment = async ( - uri: vscode.Uri, - fallbackName?: string, -): Promise< - | { file: { name: string; mimeType: string; size: number; dataUrl: string } } - | { skipped: { name: string; reason: string } } -> => { - const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file'); - - try { - const stat = await vscode.workspace.fs.stat(uri); - if ((stat.type & vscode.FileType.Directory) !== 0) { - return { skipped: { name, reason: 'Folders are not supported' } }; - } - - const size = stat.size ?? 0; - if (size > MAX_FILE_ATTACH_SIZE_BYTES) { - return { skipped: { name, reason: 'File exceeds 10MB limit' } }; - } - - const bytes = await vscode.workspace.fs.readFile(uri); - const ext = path.extname(name).toLowerCase(); - const mimeType = guessMimeTypeFromExtension(ext); - const base64 = Buffer.from(bytes).toString('base64'); - const dataUrl = `data:${mimeType};base64,${base64}`; - - return { file: { name, mimeType, size, dataUrl } }; - } catch (error) { - return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } }; - } -}; - -type ParsedDiffHunk = { - newStart: number; - oldLines: string[]; - newLines: string[]; -}; - -const VIRTUAL_DIFF_SCHEME = 'openchamber-diff'; -const virtualDiffContents = new Map(); -let virtualDiffCounter = 0; -let virtualDiffProviderDisposable: vscode.Disposable | null = null; - -const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => { - if (virtualDiffProviderDisposable) { - return; - } - - virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider( - VIRTUAL_DIFF_SCHEME, - { - provideTextDocumentContent: (uri: vscode.Uri) => { - const key = new URLSearchParams(uri.query).get('key') || ''; - return virtualDiffContents.get(key) ?? ''; - }, - }, - ); - - if (ctx?.context) { - ctx.context.subscriptions.push(virtualDiffProviderDisposable); - } -}; - -const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => { - const key = `${Date.now()}-${++virtualDiffCounter}`; - virtualDiffContents.set(key, content); - - if (virtualDiffContents.size > 100) { - const firstKey = virtualDiffContents.keys().next().value; - if (typeof firstKey === 'string') { - virtualDiffContents.delete(firstKey); - } - } - - const fileName = path.basename(modifiedPath) || 'file'; - return vscode.Uri.from({ - scheme: VIRTUAL_DIFF_SCHEME, - // Keep real filename (incl extension) so VS Code can infer language for syntax highlighting. - path: `/${fileName}`, - query: `key=${encodeURIComponent(key)}`, - }); -}; - -const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => { - if (typeof patch !== 'string' || patch.trim().length === 0) { - return []; - } - - const lines = patch.split('\n'); - const hunks: ParsedDiffHunk[] = []; - let current: ParsedDiffHunk | null = null; - - for (const rawLine of lines) { - const line = rawLine.replace(/\r$/, ''); - const headerMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (headerMatch) { - if (current) { - hunks.push(current); - } - const newStart = Number.parseInt(headerMatch[1] ?? '', 10); - current = { - newStart: Number.isFinite(newStart) ? Math.max(1, newStart) : 1, - oldLines: [], - newLines: [], - }; - continue; - } - - if (!current) { - continue; - } - - if (line.startsWith(' ')) { - const text = line.slice(1); - current.oldLines.push(text); - current.newLines.push(text); - continue; - } - - if (line.startsWith('+') && !line.startsWith('+++')) { - current.newLines.push(line.slice(1)); - continue; - } - - if (line.startsWith('-') && !line.startsWith('---')) { - current.oldLines.push(line.slice(1)); - continue; - } - } - - if (current) { - hunks.push(current); - } - - return hunks; -}; - -const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => { - const hunks = parseUnifiedDiffHunks(patch); - if (hunks.length === 0) { - return null; - } - - const lines = modifiedContent.split('\n'); - for (let index = hunks.length - 1; index >= 0; index -= 1) { - const hunk = hunks[index]; - if (!hunk) { - continue; - } - const startIndex = Math.max(0, hunk.newStart - 1); - const replaceCount = hunk.newLines.length; - lines.splice(startIndex, replaceCount, ...hunk.oldLines); - } - - return lines.join('\n'); -}; - -const isPathInside = (candidatePath: string, parentPath: string): boolean => { - const normalizedCandidate = path.resolve(candidatePath); - const normalizedParent = path.resolve(parentPath); - return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); -}; - -const findWorktreeRootForSkills = (workingDirectory?: string): string | null => { - if (!workingDirectory) return null; - let current = path.resolve(workingDirectory); - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) return null; - current = parent; - } -}; - -const getProjectAncestors = (workingDirectory?: string): string[] => { - if (!workingDirectory) return []; - const result: string[] = []; - let current = path.resolve(workingDirectory); - const stop = findWorktreeRootForSkills(workingDirectory) || current; - while (true) { - result.push(current); - if (current === stop) break; - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return result; -}; - -const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => { - const resolvedPath = path.resolve(location); - const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) - ? 'agents' - : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) - ? 'claude' - : 'opencode'; - - const projectAncestors = getProjectAncestors(workingDirectory); - const isProjectScoped = projectAncestors.some((ancestor) => { - const candidates = [ - path.join(ancestor, '.opencode'), - path.join(ancestor, '.claude', 'skills'), - path.join(ancestor, '.agents', 'skills'), - ]; - return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); - }); - - if (isProjectScoped) { - return { scope: 'project', source }; - } - - const home = os.homedir(); - const userRoots = [ - path.join(home, '.config', 'opencode'), - path.join(home, '.opencode'), - path.join(home, '.claude', 'skills'), - path.join(home, '.agents', 'skills'), - process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, - ].filter((value): value is string => Boolean(value)); - - if (userRoots.some((root) => isPathInside(resolvedPath, root))) { - return { scope: 'user', source }; - } - - return { scope: 'user', source }; -}; - -const fetchOpenCodeSkillsFromApi = async (ctx: BridgeContext | undefined, workingDirectory?: string): Promise => { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - return null; - } - - try { - const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; - const url = new URL('skill', base); - if (workingDirectory) { - url.searchParams.set('directory', workingDirectory); - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...(ctx?.manager?.getOpenCodeAuthHeaders() || {}), - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json(); - if (!Array.isArray(payload)) { - return null; - } - - return payload - .map((item) => { - const name = typeof item?.name === 'string' ? item.name.trim() : ''; - const location = typeof item?.location === 'string' ? item.location : ''; - const description = typeof item?.description === 'string' ? item.description : ''; - if (!name || !location) { - return null; - } - const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory); - return { - name, - path: location, - scope: inferred.scope, - source: inferred.source, - description, - } as DiscoveredSkill; - }) - .filter((item): item is DiscoveredSkill => item !== null); - } catch { - return null; - } -}; - -const readSharedSettingsFromDisk = (): Record => { - try { - const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - return {}; - } catch { - return {}; - } -}; - -const writeSharedSettingsToDisk = async (changes: Record): Promise => { - try { - await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); - const current = readSharedSettingsFromDisk(); - const next: Record = { ...current, ...changes }; - // Keep empty-string sentinel (""), so other runtimes can detect explicit clears. - await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8'); - } catch { - // ignore - } -}; - -const readSettings = (ctx?: BridgeContext) => { - const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; - const restStored = { ...stored }; - delete (restStored as Record).lastDirectory; - const shared = readSharedSettingsFromDisk(); - const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : ''; - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - const themeVariant = - vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || - vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight - ? 'light' - : 'dark'; - - return { - themeVariant, - lastDirectory: workspaceFolder, - ...restStored, - opencodeBinary: - typeof restStored.opencodeBinary === 'string' - ? String(restStored.opencodeBinary).trim() - : (sharedOpencodeBinary || undefined), - }; -}; - -const readStringField = (value: unknown, key: string): string => { - if (!value || typeof value !== 'object') return ''; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'string' ? candidate.trim() : ''; -}; - -const readBooleanField = (value: unknown, key: string): boolean | undefined => { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'boolean' ? candidate : undefined; -}; - -const readNumberField = (value: unknown, key: string): number | undefined => { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined; -}; - -const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => { - const trimmed = value.trim(); - if (trimmed === 'merge' || trimmed === 'squash' || trimmed === 'rebase') return trimmed; - return 'merge'; -}; - -const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; -const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; -const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; -let bridgeGitModelCatalogCache: Set | null = null; -let bridgeGitModelCatalogCacheAt = 0; -const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; - -const sleep = (ms: number) => new Promise((resolve) => { - setTimeout(resolve, ms); -}); - -const fetchBridgeGitModelCatalog = async ( - apiUrl: string, - authHeaders?: Record -): Promise> => { - const now = Date.now(); - if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) { - return bridgeGitModelCatalogCache; - } - - const headers = authHeaders || {}; - const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); - const response = await fetch(modelsUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - throw new Error('Failed to fetch model catalog'); - } - - const payload = await response.json().catch(() => null) as unknown; - const refs = new Set(); - if (Array.isArray(payload)) { - for (const item of payload) { - if (!item || typeof item !== 'object') { - continue; - } - const record = item as Record; - const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; - const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; - if (providerID && modelID) { - refs.add(`${providerID}/${modelID}`); - } - } - } - - bridgeGitModelCatalogCache = refs; - bridgeGitModelCatalogCacheAt = now; - return refs; -}; - -const resolveBridgeGitGenerationModel = async ( - payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, - settings: Record, - apiUrl: string, - authHeaders?: Record -): Promise<{ providerID: string; modelID: string }> => { - let catalog: Set | null = null; - try { - catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders); - } catch { - catalog = null; - } - - const hasModel = (providerID: string, modelID: string): boolean => { - if (!catalog) { - return false; - } - return catalog.has(`${providerID}/${modelID}`); - }; - - const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; - const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; - if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { - return { providerID: requestProviderId, modelID: requestModelId }; - } - - const settingsProviderId = readStringField(settings, 'gitProviderId'); - const settingsModelId = readStringField(settings, 'gitModelId'); - if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { - return { providerID: settingsProviderId, modelID: settingsModelId }; - } - - const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; - const settingsZenModel = readStringField(settings, 'zenModel'); - return { - providerID: 'zen', - modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, - }; -}; - -const extractTextFromMessageParts = (parts: unknown): string => { - if (!Array.isArray(parts)) { - return ''; - } - - const textParts = parts - .filter((part) => { - if (!part || typeof part !== 'object') return false; - const record = part as Record; - return record.type === 'text' && typeof record.text === 'string'; - }) - .map((part) => (part as Record).text as string) - .map((text) => text.trim()) - .filter((text) => text.length > 0); - - return textParts.join('\n').trim(); -}; - -const generateBridgeTextWithSessionFlow = async ({ - apiUrl, - directory, - prompt, - providerID, - modelID, - authHeaders, -}: { - apiUrl: string; - directory: string; - prompt: string; - providerID: string; - modelID: string; - authHeaders?: Record; -}): Promise => { - const headers = authHeaders || {}; - const apiBase = apiUrl.replace(/\/+$/, ''); - const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; - const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); - let sessionId: string | null = null; - - try { - const sessionUrl = new URL(`${apiBase}/session`); - if (directory) { - sessionUrl.searchParams.set('directory', directory); - } - - const createResponse = await fetch(sessionUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ title: 'Git Generation' }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!createResponse.ok) { - throw new Error('Failed to create OpenCode session'); - } - - const session = await createResponse.json().catch(() => null) as unknown; - const sessionObj = session && typeof session === 'object' ? session as Record : null; - const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; - if (!createdSessionId) { - throw new Error('Invalid session response'); - } - sessionId = createdSessionId; - - const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); - if (directory) { - promptUrl.searchParams.set('directory', directory); - } - - const promptResponse = await fetch(promptUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ - model: { - providerID, - modelID, - }, - parts: [{ type: 'text', text: prompt }], - }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!promptResponse.ok) { - throw new Error('Failed to send prompt'); - } - - const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); - if (directory) { - messagesUrl.searchParams.set('directory', directory); - } - messagesUrl.searchParams.set('limit', '10'); - - while (Date.now() < deadlineAt) { - await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); - - const messagesResponse = await fetch(messagesUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!messagesResponse.ok) { - continue; - } - - const messages = await messagesResponse.json().catch(() => null) as unknown; - if (!Array.isArray(messages)) { - continue; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] as Record | null; - if (!message || typeof message !== 'object') { - continue; - } - const info = message.info as Record | undefined; - if (info?.role !== 'assistant' || info?.finish !== 'stop') { - continue; - } - - const text = extractTextFromMessageParts(message.parts); - if (text) { - return text; - } - } - } - - throw new Error('Timeout waiting for generation to complete'); - } finally { - if (sessionId) { - const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); - try { - await fetch(deleteUrl.toString(), { - method: 'DELETE', - headers, - signal: AbortSignal.timeout(5_000), - }); - } catch { - // ignore cleanup failures - } - } - } -}; - -const parseJsonObjectSafe = (value: string): Record | null => { - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -}; - -const persistSettings = async (changes: Record, ctx?: BridgeContext) => { - const current = readSettings(ctx); - const restChanges = { ...(changes || {}) }; - delete restChanges.lastDirectory; - - const keysToClear = new Set(); - - // Normalize empty-string clears to key removal (match web/desktop behavior) - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { - const value = restChanges[key]; - if (typeof value === 'string' && value.trim().length === 0) { - keysToClear.add(key); - delete restChanges[key]; - } - } - - if (typeof restChanges.usageAutoRefresh !== 'boolean') { - delete restChanges.usageAutoRefresh; - } - - if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) { - restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs))); - } else { - delete restChanges.usageRefreshIntervalMs; - } - - const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record; - for (const key of keysToClear) { - delete merged[key]; - } - await ctx?.context?.globalState.update(SETTINGS_KEY, merged); - - if (keysToClear.has('opencodeBinary')) { - await writeSharedSettingsToDisk({ opencodeBinary: '' }); - } else if (typeof restChanges.opencodeBinary === 'string') { - await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() }); - } - - return merged; -}; - -const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); - -const isSocketPath = async (candidate: string): Promise => { - if (!candidate) { - return false; - } - try { - const stat = await fs.promises.stat(candidate); - return typeof stat.isSocket === 'function' && stat.isSocket(); - } catch { - return false; - } -}; - -const resolveSshAuthSock = async (): Promise => { - const existing = (process.env.SSH_AUTH_SOCK || '').trim(); - if (existing) { - return existing; - } - - if (process.platform === 'win32') { - return undefined; - } - - const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); - if (await isSocketPath(gpgSock)) { - return gpgSock; - } - - const runGpgconf = async (args: string[]): Promise => { - for (const candidate of gpgconfCandidates) { - try { - const { stdout } = await execFileAsync(candidate, args); - return String(stdout || ''); - } catch { - continue; - } - } - return ''; - }; - - const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); - if (candidate && await isSocketPath(candidate)) { - return candidate; - } - - if (candidate) { - await runGpgconf(['--launch', 'gpg-agent']); - const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); - if (retried && await isSocketPath(retried)) { - return retried; - } - } - - return undefined; -}; - -const buildGitEnv = async (): Promise => { - const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; - if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { - const resolved = await resolveSshAuthSock(); - if (resolved) { - env.SSH_AUTH_SOCK = resolved; - } - } - return env; -}; - -const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => { - const env = await buildGitEnv(); - return new Promise((resolve) => { - const proc = spawn('git', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - env, - windowsHide: true, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (code) => { - resolve({ stdout, stderr, exitCode: code ?? 0 }); - }); - - proc.on('error', (error) => { - resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); - }); - }); -}; - -const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { - if (names.length === 0) { - return new Set(); - } - - const result = await execGit(['check-ignore', '--', ...names], cwd); - if (result.exitCode !== 0 || !result.stdout) { - return new Set(); - } - - return new Set( - result.stdout - .split('\n') - .map((name: string) => name.trim()) - .filter(Boolean) - ); -}; - -const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise> => { - if (paths.length === 0) { - return new Set(); - } - - const result = await execGit(['check-ignore', '--', ...paths], cwd); - if (result.exitCode !== 0 || !result.stdout) { - return new Set(); - } - - return new Set( - result.stdout - .split('\n') - .map((name: string) => name.trim()) - .filter(Boolean) - ); -}; - -const expandTildePath = (value: string) => { - const trimmed = (value || '').trim(); - if (!trimmed) { - return trimmed; - } - - if (trimmed === '~') { - return os.homedir(); - } - - if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { - return path.join(os.homedir(), trimmed.slice(2)); - } - - return trimmed; -}; - -const resolveUserPath = (value: string, baseDirectory: string) => { - const expanded = expandTildePath(value); - if (!expanded) { - return expanded; - } - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(baseDirectory, expanded); -}; - -const listDirectoryEntries = async (dirPath: string) => { - const uri = vscode.Uri.file(dirPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - return entries.map(([name, fileType]) => ({ - name, - path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), - isDirectory: fileType === vscode.FileType.Directory, - })); -}; - -const FILE_SEARCH_EXCLUDED_DIRS = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - '.turbo', - '.cache', - 'coverage', - 'tmp', - 'logs', -]); - -const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { - if (!name) { - return false; - } - if (!includeHidden && name.startsWith('.')) { - return true; - } - return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); -}; - -/** - * Fuzzy match scoring function. - * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. - * Higher scores indicate better matches. - */ -const fuzzyMatchScore = (query: string, candidate: string): number | null => { - if (!query) return 0; - - const q = query.toLowerCase(); - const c = candidate.toLowerCase(); - - // Fast path: exact substring match gets high score - if (c.includes(q)) { - const idx = c.indexOf(q); - let bonus = 0; - if (idx === 0) { - bonus = 20; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - bonus = 15; - } - } - return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); - } - - // Fuzzy match: all query chars must appear in order - let score = 0; - let lastIndex = -1; - let consecutive = 0; - - for (let i = 0; i < q.length; i++) { - const ch = q[i]; - if (!ch || ch === ' ') continue; - - const idx = c.indexOf(ch, lastIndex + 1); - if (idx === -1) { - return null; // No match - } - - const gap = idx - lastIndex - 1; - if (gap === 0) { - consecutive++; - } else { - consecutive = 0; - } - - score += 10; - score += Math.max(0, 18 - idx); // Prefer matches near start - score -= Math.min(gap, 10); // Penalize gaps - - // Bonus for word boundary matches - if (idx === 0) { - score += 12; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - score += 10; - } - } - - score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches - lastIndex = idx; - } - - // Prefer shorter paths - score += Math.max(0, 24 - Math.floor(c.length / 3)); - - return score; -}; - -const searchFilesystemFiles = async ( - rootPath: string, - query: string, - limit: number, - includeHidden: boolean, - respectGitignore: boolean, - timeBudgetMs?: number -) => { - const normalizedQuery = (query || '').trim().toLowerCase(); - const matchAll = normalizedQuery.length === 0; - const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null; - - const rootUri = vscode.Uri.file(rootPath); - const queue: vscode.Uri[] = [rootUri]; - const visited = new Set([normalizeFsPath(rootUri.fsPath)]); - // Collect more candidates for fuzzy matching, then sort and trim - const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); - const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; - const MAX_CONCURRENCY = 10; - - while (queue.length > 0 && candidates.length < collectLimit) { - if (deadline && Date.now() > deadline) { - break; - } - const batch = queue.splice(0, MAX_CONCURRENCY); - const dirLists = await Promise.all( - batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])) - ); - - for (let index = 0; index < batch.length; index += 1) { - if (deadline && Date.now() > deadline) { - break; - } - const currentDir = batch[index]; - const dirents = dirLists[index]; - - const ignoredNames = respectGitignore - ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) - : new Set(); - - for (const [entryName, entryType] of dirents) { - if (!entryName || (!includeHidden && entryName.startsWith('.'))) { - continue; - } - - if (respectGitignore && ignoredNames.has(entryName)) { - continue; - } - - const entryUri = vscode.Uri.joinPath(currentDir, entryName); - const absolute = normalizeFsPath(entryUri.fsPath); - - if (entryType === vscode.FileType.Directory) { - if (shouldSkipSearchDirectory(entryName, includeHidden)) { - continue; - } - if (!visited.has(absolute)) { - visited.add(absolute); - queue.push(entryUri); - } - continue; - } - - if (entryType !== vscode.FileType.File) { - continue; - } - - const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); - const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; - - if (matchAll) { - candidates.push({ - name: entryName, - path: absolute, - relativePath, - extension, - score: 0, - }); - } else { - // Try fuzzy match against relative path (includes filename) - const score = fuzzyMatchScore(normalizedQuery, relativePath); - if (score !== null) { - candidates.push({ - name: entryName, - path: absolute, - relativePath, - extension, - score, - }); - } - } - - if (candidates.length >= collectLimit) { - queue.length = 0; - break; - } - } - - if (candidates.length >= collectLimit) { - break; - } - } - } - - // Sort by score descending, then by path length, then alphabetically - if (!matchAll) { - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.relativePath.length !== b.relativePath.length) { - return a.relativePath.length - b.relativePath.length; - } - return a.relativePath.localeCompare(b.relativePath); - }); - } - - // Return top results without the score field - return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ - name, - path: filePath, - relativePath, - extension, - })); -}; - -const searchDirectory = async ( - directory: string, - query: string, - limit = 60, - includeHidden = false, - respectGitignore = true -) => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const rootPath = directory - ? resolveUserPath(directory, workspaceRoot) - : vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - if (!rootPath) return []; - - const sanitizedQuery = query?.trim() || ''; - if (!sanitizedQuery) { - return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); - } - - const escapeGlob = (value: string) => value - .replace(/[\\{}()?*]/g, '\\$&') - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]'); - const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; - const mapResults = (results: vscode.Uri[]) => results.map((file) => { - const absolute = normalizeFsPath(file.fsPath); - const relative = normalizeFsPath(path.relative(rootPath, absolute)); - const name = path.basename(absolute); - return { - name, - path: absolute, - relativePath: relative || name, - extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, - }; - }); - const filterGitIgnored = async (results: vscode.Uri[]) => { - if (!respectGitignore || results.length === 0) { - return results; - } - - const relativePaths = results.map((file) => { - const relative = normalizeFsPath(path.relative(rootPath, file.fsPath)); - return relative || path.basename(file.fsPath); - }); - - const ignored = await gitCheckIgnorePaths(rootPath, relativePaths); - if (ignored.size === 0) { - return results; - } - - return results.filter((_, index) => !ignored.has(relativePaths[index])); - }; - - // Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace). - try { - const escapedQuery = escapeGlob(sanitizedQuery); - const pattern = `**/*${escapedQuery}*`; - const results = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), - exclude, - limit, - ); - - if (Array.isArray(results) && results.length > 0) { - const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.')); - const filtered = await filterGitIgnored(visible); - if (filtered.length > 0) { - return mapResults(filtered); - } - } - - if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) { - const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`; - const fuzzyResults = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern), - exclude, - limit, - ); - - if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) { - const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.')); - const filtered = await filterGitIgnored(visible); - if (filtered.length > 0) { - return mapResults(filtered); - } - } - } - } catch { - // Fall through to filesystem traversal. - } - - // Fallback: deterministic, case-insensitive traversal with early-exit at limit. - return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500); -}; - -const fetchModelsMetadata = async () => { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; - try { - const response = await fetch('https://models.dev/api.json', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`models.dev responded with ${response.status}`); - } - return await response.json(); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -}; - -const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); - -const collectHeaders = (headers: Headers): Record => { - const result: Record = {}; - headers.forEach((value, key) => { - result[key] = value; - }); - return result; -}; - -const buildUnavailableApiResponse = (): ApiProxyResponsePayload => { - const body = JSON.stringify({ error: 'OpenCode API unavailable' }); - return { - status: 503, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; -}; - -const sanitizeForwardHeaders = (input: Record | undefined): Record => { - const headers: Record = { ...(input || {}) }; - delete headers['content-length']; - delete headers['host']; - delete headers['connection']; - return headers; -}; - -const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - -const getFsMimeType = (filePath: string): string => { - const ext = path.extname(filePath).toLowerCase(); - const mimeMap: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.txt': 'text/plain; charset=utf-8', - '.md': 'text/markdown; charset=utf-8', - '.markdown': 'text/markdown; charset=utf-8', - '.mmd': 'text/plain; charset=utf-8', - '.mermaid': 'text/plain; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.pdf': 'application/pdf', - }; - return mimeMap[ext] || 'application/octet-stream'; -}; - -type FsReadPathResolution = - | { ok: true; resolvedPath: string } - | { ok: false; status: number; error: string }; - -const resolveFileReadPath = async (targetPath: string): Promise => { - const trimmed = targetPath.trim(); - if (!trimmed) { - return { ok: false, status: 400, error: 'Path is required' }; - } - - const baseRoot = getFsAccessRoot(); - const resolved = resolveUserPath(trimmed, baseRoot); - if (!resolved) { - return { ok: false, status: 400, error: 'Path is required' }; - } - - try { - const [canonicalPath, canonicalBase] = await Promise.all([ - fs.promises.realpath(resolved), - fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)), - ]); - - if (!isPathInside(canonicalPath, canonicalBase)) { - return { ok: false, status: 403, error: 'Access to file denied' }; - } - - return { ok: true, resolvedPath: canonicalPath }; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err?.code === 'ENOENT') { - return { ok: false, status: 404, error: 'File not found' }; - } - return { ok: false, status: 500, error: 'Failed to resolve file path' }; - } -}; - -const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePayload => ({ - status, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(JSON.stringify({ error })), -}); - -const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise => { - let parsed: URL; - try { - parsed = new URL(requestPath, 'https://openchamber.local'); - } catch { - return buildProxyJsonError(400, 'Invalid request path'); - } - - if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { - return null; - } - - if (method !== 'GET' && method !== 'HEAD') { - return buildProxyJsonError(405, 'Method not allowed'); - } - - const targetPath = parsed.searchParams.get('path') || ''; - const resolution = await resolveFileReadPath(targetPath); - if (!resolution.ok) { - return buildProxyJsonError(resolution.status, resolution.error); - } - - try { - const stats = await fs.promises.stat(resolution.resolvedPath); - if (!stats.isFile()) { - return buildProxyJsonError(400, 'Specified path is not a file'); - } - - if (parsed.pathname === '/api/fs/read') { - const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); - return { - status: 200, - headers: { - 'content-type': 'text/plain; charset=utf-8', - 'cache-control': 'no-store', - }, - bodyBase64: base64EncodeUtf8(content), - }; - } - - const raw = await fs.promises.readFile(resolution.resolvedPath); - return { - status: 200, - headers: { - 'content-type': getFsMimeType(resolution.resolvedPath), - 'cache-control': 'no-store', - }, - bodyBase64: Buffer.from(raw).toString('base64'), - }; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err?.code === 'ENOENT') { - return buildProxyJsonError(404, 'File not found'); - } - return buildProxyJsonError(500, 'Unable to read file'); - } -}; export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { const { id, type, payload } = message; try { + const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload }); + if (standardGitResponse) { + return standardGitResponse; + } + const specialGitResponse = await handleSpecialGitBridgeMessage( + { id, type, payload }, + ctx, + { readSettings, execGit } + ); + if (specialGitResponse) { + return specialGitResponse; + } + const fsResponse = await handleFsBridgeMessage( + { id, type, payload }, + { + resolveUserPath, + listDirectoryEntries, + normalizeFsPath, + execGit, + searchDirectory, + resolveFileReadPath, + parseDroppedFileReference, + readUriAsAttachment, + } + ); + if (fsResponse) { + return fsResponse; + } + const configResponse = await handleConfigBridgeMessage( + { id, type, payload }, + ctx, + { + readSettings, + persistSettings, + fetchOpenCodeSkillsFromApi, + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + ); + if (configResponse) { + return configResponse; + } + const systemResponse = await handleSystemBridgeMessage( + { id, type, payload }, + ctx, + { + resolveUserPath, + fetchModelsMetadata, + updateCheckUrl: UPDATE_CHECK_URL, + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + ); + if (systemResponse) { + return systemResponse; + } + const proxyResponse = await handleProxyBridgeMessage( + { id, type, payload }, + ctx, + { + tryHandleLocalFsProxy, + buildUnavailableApiResponse, + sanitizeForwardHeaders, + collectHeaders, + base64EncodeUtf8, + }, + ); + if (proxyResponse) { + return proxyResponse; + } + switch (type) { - case 'api:proxy': { - const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - const normalizedPath = - typeof requestPath === 'string' && requestPath.trim().length > 0 - ? requestPath.trim().startsWith('/') - ? requestPath.trim() - : `/${requestPath.trim()}` - : '/'; - - const localFsResponse = await tryHandleLocalFsProxy(normalizedMethod, normalizedPath); - if (localFsResponse) { - return { id, type, success: true, data: localFsResponse }; - } - - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - const data = buildUnavailableApiResponse(); - return { id, type, success: true, data }; - } - - const base = `${apiUrl.replace(/\/+$/, '')}/`; - const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = { - ...sanitizeForwardHeaders(headers), - ...ctx?.manager?.getOpenCodeAuthHeaders(), - }; - - // Ensure SSE requests are negotiated correctly. - if (normalizedPath === '/event' || normalizedPath === '/global/event') { - if (!requestHeaders.Accept) { - requestHeaders.Accept = 'text/event-stream'; - } - requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; - requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; - } - - try { - const response = await fetch(targetUrl, { - method: normalizedMethod, - headers: requestHeaders, - body: - typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' - ? Buffer.from(bodyBase64, 'base64') - : undefined, - }); - - const arrayBuffer = await response.arrayBuffer(); - const data: ApiProxyResponsePayload = { - status: response.status, - headers: collectHeaders(response.headers), - bodyBase64: Buffer.from(arrayBuffer).toString('base64'), - }; - - return { id, type, success: true, data }; - } catch (error) { - const body = JSON.stringify({ - error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', - }); - const data: ApiProxyResponsePayload = { - status: 502, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - } - - case 'api:session:message': { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - const data = buildUnavailableApiResponse(); - return { id, type, success: true, data }; - } - - const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload; - const normalizedPath = - typeof requestPath === 'string' && requestPath.trim().length > 0 - ? requestPath.trim().startsWith('/') - ? requestPath.trim() - : `/${requestPath.trim()}` - : '/'; - - if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) { - const body = JSON.stringify({ error: 'Invalid session message proxy path' }); - const data: ApiProxyResponsePayload = { - status: 400, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - - const base = `${apiUrl.replace(/\/+$/, '')}/`; - const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = { - ...sanitizeForwardHeaders(headers), - ...ctx?.manager?.getOpenCodeAuthHeaders(), - }; - - try { - const response = await fetch(targetUrl, { - method: 'POST', - headers: requestHeaders, - body: typeof bodyText === 'string' ? bodyText : '', - signal: AbortSignal.timeout(45000), - }); - - const arrayBuffer = await response.arrayBuffer(); - const data: ApiProxyResponsePayload = { - status: response.status, - headers: collectHeaders(response.headers), - bodyBase64: Buffer.from(arrayBuffer).toString('base64'), - }; - - return { id, type, success: true, data }; - } catch (error) { - const isTimeout = - error instanceof Error && - ((error as Error & { name?: string }).name === 'TimeoutError' || - (error as Error & { name?: string }).name === 'AbortError'); - const body = JSON.stringify({ - error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed', - }); - const data: ApiProxyResponsePayload = { - status: isTimeout ? 504 : 503, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - } - - case 'files:list': { - const { path: dirPath } = payload as { path: string }; - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(dirPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - const result: FileEntry[] = entries.map(([name, fileType]) => ({ - name, - path: vscode.Uri.joinPath(uri, name).fsPath, - isDirectory: fileType === vscode.FileType.Directory, - })); - return { id, type, success: true, data: { directory: normalizeFsPath(resolvedPath), entries: result } }; - } - - case 'files:search': { - const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; - const pattern = `**/*${query}*`; - const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); - const results: FileSearchResult[] = files.map((file) => ({ - path: file.fsPath, - })); - return { id, type, success: true, data: results }; - } - - case 'workspace:folder': { - const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - return { id, type, success: true, data: { folder } }; - } - - case 'config:get': { - const { key } = payload as { key: string }; - const config = vscode.workspace.getConfiguration('openchamber'); - const value = config.get(key); - return { id, type, success: true, data: { value } }; - } - - case 'api:fs:list': { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; - const target = targetPath || workspaceRoot; - const resolvedPath = resolveUserPath(target, workspaceRoot) || workspaceRoot; - - const entries = await listDirectoryEntries(resolvedPath); - const normalized = normalizeFsPath(resolvedPath); - - if (!respectGitignore) { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - - const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); - if (pathsToCheck.length === 0) { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - - try { - const result = await execGit(['check-ignore', '--', ...pathsToCheck], normalized); - const ignoredNames = new Set( - result.stdout - .split('\n') - .map((name) => name.trim()) - .filter(Boolean) - ); - - const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); - return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; - } catch { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - } - - case 'api:fs:search': { - const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { - directory?: string; - query?: string; - limit?: number; - includeHidden?: boolean; - respectGitignore?: boolean; - }; - const files = await searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); - return { id, type, success: true, data: { files } }; - } - - case 'api:fs:mkdir': { - const target = (payload as { path: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(target, workspaceRoot); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; - } - - case 'api:fs/home': { - // Match web/desktop semantics: OS home directory. - return { id, type, success: true, data: { home: normalizeFsPath(os.homedir()) } }; - } - - case 'api:fs:read': { - const target = (payload as { path: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - - const resolution = await resolveFileReadPath(target); - if (!resolution.ok) { - return { id, type, success: false, error: resolution.error }; - } - - try { - const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); - return { id, type, success: true, data: { content, path: normalizeFsPath(resolution.resolvedPath) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to read file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:write': { - const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; - if (!targetPath) { - return { id, type, success: false, error: 'Path is required' }; - } - if (typeof content !== 'string') { - return { id, type, success: false, error: 'Content is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(targetPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - // Ensure parent directory exists - const parentUri = vscode.Uri.file(path.dirname(resolvedPath)); - try { - await vscode.workspace.fs.createDirectory(parentUri); - } catch { - // Directory may already exist - } - await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to write file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:delete': { - const targetPath = (payload as { path: string })?.path; - if (!targetPath) { - return { id, type, success: false, error: 'Path is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(targetPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); - return { id, type, success: true, data: { success: true } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:rename': { - const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {}; - if (!oldPath) { - return { id, type, success: false, error: 'oldPath is required' }; - } - if (!newPath) { - return { id, type, success: false, error: 'newPath is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedOld = resolveUserPath(oldPath, workspaceRoot); - const resolvedNew = resolveUserPath(newPath, workspaceRoot); - const oldUri = vscode.Uri.file(resolvedOld); - const newUri = vscode.Uri.file(resolvedNew); - await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false }); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedNew) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to rename file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:exec': { - const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {}; - if (!Array.isArray(commands) || commands.length === 0) { - return { id, type, success: false, error: 'Commands array is required' }; - } - if (!cwd) { - return { id, type, success: false, error: 'Working directory (cwd) is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedCwd = resolveUserPath(cwd, workspaceRoot); - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - - const augmentedEnv = { - ...process.env, - PATH: process.env.PATH, - }; - - const results: Array<{ - command: string; - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; - }> = []; - - for (const cmd of commands) { - if (typeof cmd !== 'string' || !cmd.trim()) { - results.push({ command: cmd, success: false, error: 'Invalid command' }); - continue; - } - try { - // Use async exec to not block the extension host event loop - const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, { - cwd: resolvedCwd, - env: augmentedEnv, - timeout: 300000, // 5 minutes per command - }); - results.push({ - command: cmd, - success: true, - exitCode: 0, - stdout: (stdout || '').trim(), - stderr: (stderr || '').trim(), - }); - } catch (execError) { - const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string }; - results.push({ - command: cmd, - success: false, - exitCode: typeof err.code === 'number' ? err.code : 1, - stdout: (err.stdout || '').trim(), - stderr: (err.stderr || '').trim(), - error: err.message, - }); - } - } - - const allSucceeded = results.every((r) => r.success); - return { id, type, success: true, data: { success: allSucceeded, results } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to execute commands'; - return { id, type, success: false, error: message }; - } - } - - case 'api:files/pick': { - const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; - const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; - - const picks = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: allowMany, - defaultUri, - openLabel: 'Attach', - }); - - if (!picks || picks.length === 0) { - return { id, type, success: true, data: { files: [], skipped: [] } }; - } - - const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; - const skipped: Array<{ name: string; reason: string }> = []; - - for (const uri of picks) { - const result = await readUriAsAttachment(uri); - if ('file' in result) { - files.push(result.file); - } else { - skipped.push(result.skipped); - } - } - - return { id, type, success: true, data: { files, skipped } }; - } - - case 'api:files/drop': { - const uris = Array.isArray((payload as { uris?: unknown[] })?.uris) - ? (payload as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - : []; - - if (uris.length === 0) { - return { id, type, success: true, data: { files: [], skipped: [] } }; - } - - const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; - const skipped: Array<{ name: string; reason: string }> = []; - - const dedupedUris = Array.from(new Set(uris.map((value) => value.trim()))); - - for (const rawUri of dedupedUris) { - const parsed = parseDroppedFileReference(rawUri); - if ('skipped' in parsed) { - skipped.push(parsed.skipped); - continue; - } - - const uri = parsed.uri; - - const name = path.basename(uri.fsPath || uri.path || rawUri); - - const result = await readUriAsAttachment(uri, name); - if ('file' in result) { - files.push(result.file); - } else { - skipped.push(result.skipped); - } - } - - return { id, type, success: true, data: { files, skipped } }; - } - - case 'api:files/save-image': { - const rawFileName = (payload as { fileName?: unknown })?.fileName; - const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl; - const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : ''; - if (!dataUrl.startsWith('data:image/')) { - return { id, type, success: false, error: 'Invalid image payload' }; - } - - const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0 - ? rawFileName.trim() - : `message-${Date.now()}.png`; - - const saveUri = await vscode.window.showSaveDialog({ - saveLabel: 'Save image', - defaultUri: vscode.workspace.workspaceFolders?.[0] - ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName) - : undefined, - filters: { Images: ['png'] }, - }); - - if (!saveUri) { - return { id, type, success: true, data: { saved: false, canceled: true } }; - } - - const commaIndex = dataUrl.indexOf(','); - if (commaIndex === -1) { - return { id, type, success: false, error: 'Invalid image data URL' }; - } - - const base64 = dataUrl.slice(commaIndex + 1); - const bytes = Buffer.from(base64, 'base64'); - await vscode.workspace.fs.writeFile(saveUri, bytes); - - return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } }; - } - - case 'api:config/settings:get': { - const settings = readSettings(ctx); - return { id, type, success: true, data: settings }; - } - - case 'api:config/settings:save': { - const changes = (payload as Record) || {}; - const updated = await persistSettings(changes, ctx); - return { id, type, success: true, data: updated }; - } - - case 'api:github/auth:status': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const list = await readGitHubAuthList(context); - const accounts = list - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - - const stored = list.find((entry) => entry.current) || list[0]; - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false, accounts } }; - } - - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - const updatedAccounts = (await readGitHubAuthList(context)) - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/auth:start': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const settings = readSettings(ctx); - const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID; - const scopes = readStringField(settings, 'githubScopes') || DEFAULT_GITHUB_SCOPES; - const flow = await startDeviceFlow(clientId, scopes); - return { id, type, success: true, data: flow }; - } - - case 'api:github/auth:complete': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const deviceCode = readStringField(payload, 'deviceCode'); - if (!deviceCode) return { id, type, success: false, error: 'deviceCode is required' }; - - const settings = readSettings(ctx); - const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID; - - const token = await exchangeDeviceCode(clientId, deviceCode); - const tokenRecord = token && typeof token === 'object' ? (token as Record) : null; - const tokenError = typeof tokenRecord?.error === 'string' ? tokenRecord.error : ''; - const tokenErrorDescription = typeof tokenRecord?.error_description === 'string' ? tokenRecord.error_description : ''; - if (tokenError) { - return { - id, - type, - success: true, - data: { - connected: false, - status: tokenError, - error: tokenErrorDescription || tokenError, - }, - }; - } - const accessToken = typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : ''; - if (!accessToken) { - return { id, type, success: false, error: 'Missing access_token from GitHub' }; - } - - const user = await fetchMe(accessToken); - await writeGitHubAuth(context, { - accessToken, - scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined, - tokenType: typeof tokenRecord?.token_type === 'string' ? tokenRecord.token_type : undefined, - createdAt: Date.now(), - user, - }); - - return { - id, - type, - success: true, - data: { - connected: true, - user, - scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined, - }, - }; - } - - case 'api:github/auth:disconnect': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const removed = await clearGitHubAuth(context); - return { id, type, success: true, data: { removed } }; - } - - case 'api:github/auth:activate': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const accountId = readStringField(payload, 'accountId'); - if (!accountId) return { id, type, success: false, error: 'accountId is required' }; - const activated = await activateGitHubAuth(context, accountId); - if (!activated) return { id, type, success: false, error: 'GitHub account not found' }; - const list = await readGitHubAuthList(context); - const accounts = list - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - const stored = list.find((entry) => entry.current) || list[0]; - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false, accounts } }; - } - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - const updatedAccounts = (await readGitHubAuthList(context)) - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/me': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: user }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - return { id, type, success: false, error: 'GitHub token expired or revoked' }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:status': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const directory = readStringField(payload, 'directory'); - const branch = readStringField(payload, 'branch'); - if (!directory || !branch) { - return { id, type, success: false, error: 'directory and branch are required' }; - } - - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - - try { - const result = await getPullRequestStatus( - stored.accessToken, - stored.user?.login || null, - directory, - branch, - ); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:create': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const title = readStringField(payload, 'title'); - const head = readStringField(payload, 'head'); - const base = readStringField(payload, 'base'); - const body = readStringField(payload, 'body'); - const draft = readBooleanField(payload, 'draft'); - if (!directory || !title || !head || !base) { - return { id, type, success: false, error: 'directory, title, head, base are required' }; - } - try { - const pr = await createPullRequest(stored.accessToken, directory, { - directory, - title, - head, - base, - ...(body ? { body } : {}), - ...(typeof draft === 'boolean' ? { draft } : {}), - }); - return { id, type, success: true, data: pr }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:update': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - const title = readStringField(payload, 'title'); - const body = readStringField(payload, 'body'); - if (!directory || !number || !title) { - return { id, type, success: false, error: 'directory, number, title are required' }; - } - try { - const pr = await updatePullRequest(stored.accessToken, directory, { - directory, - number, - title, - ...(typeof body === 'string' ? { body } : {}), - }); - return { id, type, success: true, data: pr }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:merge': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const method = normalizeMergeMethod(readStringField(payload, 'method') || 'merge'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await mergePullRequest(stored.accessToken, directory, { - directory, - number, - method, - }); - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:ready': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await markPullRequestReady(stored.accessToken, directory, number); - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:list': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const page = readNumberField(payload, 'page') ?? 1; - if (!directory) { - return { id, type, success: false, error: 'directory is required' }; - } - try { - const result = await listIssues(stored.accessToken, directory, page); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:get': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await getIssue(stored.accessToken, directory, number); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:comments': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await listIssueComments(stored.accessToken, directory, number); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pulls:list': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const page = readNumberField(payload, 'page') ?? 1; - if (!directory) { - return { id, type, success: false, error: 'directory is required' }; - } - try { - const result = await listPullRequests(stored.accessToken, directory, page); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - + case 'api:github/auth:status': + case 'api:github/auth:start': + case 'api:github/auth:complete': + case 'api:github/auth:disconnect': + case 'api:github/auth:activate': + case 'api:github/me': + case 'api:github/pr:status': + case 'api:github/pr:create': + case 'api:github/pr:update': + case 'api:github/pr:merge': + case 'api:github/pr:ready': + case 'api:github/issues:list': + case 'api:github/issues:get': + case 'api:github/issues:comments': + case 'api:github/pulls:list': case 'api:github/pulls:context': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - const includeDiff = readBooleanField(payload, 'includeDiff') ?? false; - const includeCheckDetails = readBooleanField(payload, 'includeCheckDetails') ?? false; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff, includeCheckDetails); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:config/reload': { - await ctx?.manager?.restart(); - return { id, type, success: true, data: { restarted: true } }; - } - - case 'api:config/agents': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const agentName = typeof name === 'string' ? name.trim() : ''; - if (!agentName) { - return { id, type, success: false, error: 'Agent name is required' }; - } - - // Use directory from request if provided, otherwise fall back to workspace - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const sources = getAgentSources(agentName, workingDirectory); - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - return { - id, - type, - success: true, - data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, - }; - } - - if (normalizedMethod === 'POST') { - // Extract scope from body if present - const scopeValue = body?.scope as string | undefined; - const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; - createAgent(agentName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateAgent(agentName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteAgent(agentName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/commands': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const commandName = typeof name === 'string' ? name.trim() : ''; - if (!commandName) { - return { id, type, success: false, error: 'Command name is required' }; - } - - // Use directory from request if provided, otherwise fall back to workspace - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const sources = getCommandSources(commandName, workingDirectory); - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - return { - id, - type, - success: true, - data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, - }; - } - - if (normalizedMethod === 'POST') { - // Extract scope from body if present - const scopeValue = body?.scope as string | undefined; - const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; - createCommand(commandName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateCommand(commandName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteCommand(commandName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/mcp': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - const mcpName = typeof name === 'string' ? name.trim() : ''; - - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - if (normalizedMethod === 'GET' && !mcpName) { - const configs = listMcpConfigs(workingDirectory); - return { id, type, success: true, data: configs }; - } - - if (!mcpName) { - return { id, type, success: false, error: 'MCP server name is required' }; - } - - if (normalizedMethod === 'GET') { - const config = getMcpConfig(mcpName, workingDirectory); - if (!config) { - return { id, type, success: false, error: `MCP server "${mcpName}" not found` }; - } - return { id, type, success: true, data: config }; - } - - if (normalizedMethod === 'POST') { - const scope = body?.scope as 'user' | 'project' | undefined; - createMcpConfig(mcpName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" created. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateMcpConfig(mcpName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" updated. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteMcpConfig(mcpName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" deleted. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/skills': { - const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - - // LIST all skills (no name provided) - if (!name && normalizedMethod === 'GET') { - const skills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory); - return { id, type, success: true, data: { skills } }; - } - - const skillName = typeof name === 'string' ? name.trim() : ''; - if (!skillName) { - return { id, type, success: false, error: 'Skill name is required' }; - } - - if (normalizedMethod === 'GET') { - const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) - .find((skill) => skill.name === skillName); - const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); - return { - id, - type, - success: true, - data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source }, - }; - } - - if (normalizedMethod === 'POST') { - const scopeValue = body?.scope as string | undefined; - const sourceValue = body?.source as string | undefined; - const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; - const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; - createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateSkill(skillName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteSkill(skillName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/skills:catalog': { - const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const settings = readSettings(ctx); - const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; - - const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs) - ? (rawCatalogs - .map((entry) => { - if (!entry || typeof entry !== 'object') return null; - const candidate = entry as Record; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; - const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; - const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; - if (!id || !label || !source) return null; - const normalized: SkillsCatalogSourceConfig = { - id, - label, - description: source, - source, - ...(subpath ? { defaultSubpath: subpath } : {}), - }; - return normalized; - }) - .filter((v) => v !== null) as SkillsCatalogSourceConfig[]) - : []; - - const installedSkills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined; - const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills); - return { id, type, success: true, data }; - } - - case 'api:config/skills:scan': { - const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; - const data = await scanSkillsRepositoryFromGit({ - source: String(body.source || ''), - subpath: body.subpath, - }); - return { id, type, success: true, data }; - } - - case 'api:config/skills:install': { - const body = (payload || {}) as { - source?: string; - subpath?: string; - scope?: 'user' | 'project'; - targetSource?: 'opencode' | 'agents'; - selections?: Array<{ skillDir: string }>; - conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; - conflictDecisions?: Record; - }; - - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const data = await installSkillsFromGit({ - source: String(body.source || ''), - subpath: body.subpath, - scope: body.scope === 'project' ? 'project' : 'user', - targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode', - workingDirectory: body.scope === 'project' ? workingDirectory : undefined, - selections: Array.isArray(body.selections) ? body.selections : [], - conflictPolicy: body.conflictPolicy, - conflictDecisions: body.conflictDecisions, - }); - - if (data.ok) { - const installed = data.installed || []; - const skipped = data.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await ctx?.manager?.restart(); - } - - return { - id, - type, - success: true, - data: { - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }, - }; - } - - return { id, type, success: true, data }; - } - - case 'api:config/skills/files': { - const { method, name, filePath, content } = (payload || {}) as { - method?: string; - name?: string; - filePath?: string; - content?: string; - }; - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const skillName = typeof name === 'string' ? name.trim() : ''; - if (!skillName) { - return { id, type, success: false, error: 'Skill name is required' }; - } - - const relativePath = typeof filePath === 'string' ? filePath.trim() : ''; - if (!relativePath) { - return { id, type, success: false, error: 'File path is required' }; - } - - const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) - .find((skill) => skill.name === skillName); - const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); - if (!sources.md.dir) { - return { id, type, success: false, error: `Skill "${skillName}" not found` }; - } - - const skillDir = sources.md.dir; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const fileContent = readSkillSupportingFile(skillDir, relativePath); - if (fileContent === null) { - return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` }; - } - return { id, type, success: true, data: { content: fileContent } }; - } - - if (normalizedMethod === 'PUT') { - writeSkillSupportingFile(skillDir, relativePath, content || ''); - return { id, type, success: true, data: { success: true } }; - } - - if (normalizedMethod === 'DELETE') { - deleteSkillSupportingFile(skillDir, relativePath); - return { id, type, success: true, data: { success: true } }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:opencode/directory': { - const target = (payload as { path?: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - const baseDirectory = - ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(target, baseDirectory); - const result = await ctx?.manager?.setWorkingDirectory(resolvedPath); - if (!result) { - return { id, type, success: false, error: 'OpenCode manager unavailable' }; - } - return { id, type, success: true, data: result }; - } - - case 'api:models/metadata': { - try { - const data = await fetchModelsMetadata(); - return { id, type, success: true, data }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:openchamber:update-check': { - try { - const body = (payload && typeof payload === 'object' ? payload : {}) as Record; - const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0 - ? body.currentVersion.trim() - : 'unknown'; - const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0 - ? body.instanceMode.trim() - : 'local'; - const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 - ? body.deviceClass.trim() - : 'desktop'; - const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0 - ? body.platform.trim() - : os.platform(); - const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0 - ? body.arch.trim() - : os.arch(); - const reportUsage = body.reportUsage !== false; - - const installId = getOrCreateInstallId('vscode'); - const requestBody = { - appType: 'vscode', - deviceClass, - platform: mapNodePlatformToApiPlatform(platformRaw), - arch: mapNodeArchToApiArch(archRaw), - channel: 'stable', - currentVersion, - installId, - instanceMode, - reportUsage, - }; - - const response = await fetch(UPDATE_CHECK_URL, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { - const text = await response.text().catch(() => 'update check failed'); - return { id, type, success: false, error: text || `Update check failed with ${response.status}` }; - } - - const data = await response.json(); - return { id, type, success: true, data }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'editor:openFile': { - const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; - try { - const doc = await vscode.workspace.openTextDocument(filePath); - const options: vscode.TextDocumentShowOptions = {}; - if (typeof line === 'number') { - const pos = new vscode.Position(Math.max(0, line - 1), column || 0); - options.selection = new vscode.Range(pos, pos); - } - await vscode.window.showTextDocument(doc, options); - return { id, type, success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'editor:openDiff': { - const { original, modified, label, line, patch } = payload as { - original: string; - modified: string; - label?: string; - line?: number; - patch?: string; - }; - try { - const modifiedUri = vscode.Uri.file(modified); - const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri); - let originalUri = original ? vscode.Uri.file(original) : modifiedUri; - - if (typeof patch === 'string' && patch.trim().length > 0) { - const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch); - if (typeof originalContent === 'string') { - ensureVirtualDiffProviderRegistered(ctx); - originalUri = createVirtualOriginalDiffUri(modified, originalContent); - } - } - - const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`; - const title = label || `${leftLabel} ↔ ${path.basename(modified)}`; - - await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); - - if (typeof line === 'number' && Number.isFinite(line)) { - const targetLine = Math.max(0, Math.trunc(line) - 1); - await new Promise((resolve) => setTimeout(resolve, 0)); - const targetEditor = vscode.window.visibleTextEditors.find( - (editor) => editor.document.uri.toString() === modifiedUri.toString(), - ); - if (targetEditor) { - const target = new vscode.Position(targetLine, 0); - targetEditor.selection = new vscode.Selection(target, target); - targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter); - } - } - - return { id, type, success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:provider/auth:delete': { - const { providerId, scope } = (payload || {}) as { providerId?: string; scope?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - const normalizedScope = typeof scope === 'string' ? scope : 'auth'; - try { - let removed = false; - if (normalizedScope === 'auth') { - removed = removeProviderAuth(providerId); - } else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') { - removed = removeProviderConfig(providerId, ctx?.manager?.getWorkingDirectory(), normalizedScope); - } else if (normalizedScope === 'all') { - const workingDirectory = ctx?.manager?.getWorkingDirectory(); - const authRemoved = removeProviderAuth(providerId); - const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user'); - const projectRemoved = workingDirectory - ? removeProviderConfig(providerId, workingDirectory, 'project') - : false; - const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom'); - removed = authRemoved || userRemoved || projectRemoved || customRemoved; - } else { - return { id, type, success: false, error: 'Invalid scope' }; - } - - if (removed) { - await ctx?.manager?.restart(); - } - return { - id, - type, - success: true, - data: { - success: true, - removed, - requiresReload: removed, - message: removed - ? `Provider ${providerId} disconnected successfully. Reloading interface…` - : `Provider ${providerId} was not configured.`, - reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:provider/source:get': { - const { providerId } = (payload || {}) as { providerId?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - try { - const sources = getProviderSources(providerId, ctx?.manager?.getWorkingDirectory()); - const auth = getProviderAuth(providerId); - sources.auth.exists = Boolean(auth); - return { id, type, success: true, data: { providerId, sources } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:quota:providers': { - try { - const providers = listConfiguredQuotaProviders(); - return { id, type, success: true, data: { providers } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:quota:get': { - const { providerId } = (payload || {}) as { providerId?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - try { - const result = await fetchQuotaForProvider(providerId); - return { id, type, success: true, data: result }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - - case 'vscode:command': { - const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; - if (!command) { - return { id, type, success: false, error: 'Command is required' }; - } - try { - const result = await vscode.commands.executeCommand(command, ...(args || [])); - return { id, type, success: true, data: { result } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'vscode:openExternalUrl': { - const { url } = (payload || {}) as { url?: string }; - const target = typeof url === 'string' ? url.trim() : ''; - if (!target) { - return { id, type, success: false, error: 'URL is required' }; - } - try { - await vscode.env.openExternal(vscode.Uri.parse(target)); - return { id, type, success: true, data: { opened: true } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'notifications:can-notify': { - return { id, type, success: true, data: true }; - } - - case 'notifications:notify': { - const request = (payload || {}) as NotificationsNotifyRequestPayload; - const notification = request.payload || {}; - const title = typeof notification.title === 'string' ? notification.title.trim() : ''; - const body = typeof notification.body === 'string' ? notification.body.trim() : ''; - - const message = title && body - ? `${title}: ${body}` - : title || body; - - if (!message) { - return { id, type, success: true, data: { shown: false } }; - } - - void vscode.window.showInformationMessage(message); - return { id, type, success: true, data: { shown: true } }; - } - - // ============== Git Operations ============== - - case 'api:git/check': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const isRepo = await gitService.checkIsGitRepository(directory); - return { id, type, success: true, data: isRepo }; - } - - case 'api:git/worktree-type': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const isLinked = await gitService.isLinkedWorktree(directory); - return { id, type, success: true, data: isLinked }; - } - - case 'api:git/status': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const status = await gitService.getGitStatus(directory); - return { id, type, success: true, data: status }; - } - - case 'api:git/branches': { - const { directory, method, name, startPoint, force } = (payload || {}) as { - directory?: string; - method?: string; - name?: string; - startPoint?: string; - force?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const branches = await gitService.getGitBranches(directory); - return { id, type, success: true, data: branches }; - } - - if (normalizedMethod === 'POST') { - if (!name) { - return { id, type, success: false, error: 'Branch name is required' }; - } - const result = await gitService.createBranch(directory, name, startPoint); - return { id, type, success: true, data: result }; - } - - if (normalizedMethod === 'DELETE') { - if (!name) { - return { id, type, success: false, error: 'Branch name is required' }; - } - const result = await gitService.deleteGitBranch(directory, name, force); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/remote-branches': { - const { directory, branch, remote } = (payload || {}) as { - directory?: string; - branch?: string; - remote?: string; - }; - if (!directory || !branch) { - return { id, type, success: false, error: 'Directory and branch are required' }; - } - const result = await gitService.deleteRemoteBranch(directory, branch, remote); - return { id, type, success: true, data: result }; - } - - case 'api:git/checkout': { - const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; - if (!directory || !branch) { - return { id, type, success: false, error: 'Directory and branch are required' }; - } - const result = await gitService.checkoutBranch(directory, branch); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees': { - const { directory, method } = (payload || {}) as { - directory?: string; - method?: string; - body?: unknown; - directoryPath?: string; - deleteLocalBranch?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const worktrees = await gitService.listGitWorktrees(directory); - return { id, type, success: true, data: worktrees }; - } - - if (normalizedMethod === 'POST') { - const created = await gitService.createWorktree(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: created }; - } - - if (normalizedMethod === 'DELETE') { - const removePayload = payload as { - body?: { directory?: string; deleteLocalBranch?: boolean }; - directory?: string; - deleteLocalBranch?: boolean; - }; - const bodyDirectory = typeof removePayload?.body?.directory === 'string' - ? removePayload.body.directory - : ''; - const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : ''; - const worktreeDirectory = bodyDirectory || legacyDirectory || ''; - - if (!worktreeDirectory) { - return { id, type, success: false, error: 'Worktree directory is required' }; - } - const removed = await gitService.removeWorktree(directory, { - directory: worktreeDirectory, - deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true, - }); - return { id, type, success: true, data: { success: Boolean(removed) } }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/worktrees/validate': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.validateWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees/bootstrap-status': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.getWorktreeBootstrapStatus(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees/preview': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.previewWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: result }; - } - - case 'api:git/diff': { - const { directory, path: filePath, staged, contextLines } = (payload || {}) as { - directory?: string; - path?: string; - staged?: boolean; - contextLines?: number; - }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); - return { id, type, success: true, data: result }; - } - - case 'api:git/file-diff': { - const { directory, path: filePath, staged } = (payload || {}) as { - directory?: string; - path?: string; - staged?: boolean; - }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - const result = await gitService.getGitFileDiff(directory, filePath, staged); - return { id, type, success: true, data: result }; - } - - case 'api:git/revert': { - const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - await gitService.revertGitFile(directory, filePath); - return { id, type, success: true, data: { success: true } }; - } - - case 'api:git/commit': { - const { directory, message, addAll, files } = (payload || {}) as { - directory?: string; - message?: string; - addAll?: boolean; - files?: string[]; - }; - if (!directory || !message) { - return { id, type, success: false, error: 'Directory and message are required' }; - } - const result = await gitService.createGitCommit(directory, message, { addAll, files }); - return { id, type, success: true, data: result }; - } - - case 'api:git/push': { - const { directory, remote, branch, options } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - options?: string[] | Record; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitPush(directory, { remote, branch, options }); - return { id, type, success: true, data: result }; - } - - case 'api:git/pull': { - const { directory, remote, branch } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitPull(directory, { remote, branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/fetch': { - const { directory, remote, branch } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitFetch(directory, { remote, branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/remotes': { - const { directory, method, remote } = (payload || {}) as { - directory?: string; - method?: string; - remote?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const result = await gitService.getRemotes(directory); - return { id, type, success: true, data: result }; - } - - if (normalizedMethod === 'DELETE') { - if (!remote) { - return { id, type, success: false, error: 'Remote name is required' }; - } - const result = await gitService.removeRemote(directory, remote); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/rebase': { - const { directory, onto } = (payload || {}) as { directory?: string; onto?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!onto) { - return { id, type, success: false, error: 'onto is required' }; - } - const result = await gitService.rebase(directory, { onto }); - return { id, type, success: true, data: result }; - } - - case 'api:git/rebase/abort': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.abortRebase(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge': { - const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!branch) { - return { id, type, success: false, error: 'branch is required' }; - } - const result = await gitService.merge(directory, { branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge/abort': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.abortMerge(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/rebase/continue': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.continueRebase(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge/continue': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.continueMerge(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/stash': { - const { directory, message, includeUntracked } = (payload || {}) as { - directory?: string; - message?: string; - includeUntracked?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.stash(directory, { message, includeUntracked }); - return { id, type, success: true, data: result }; - } - - case 'api:git/stash/pop': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.stashPop(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/log': { - const { directory, maxCount, from, to, file } = (payload || {}) as { - directory?: string; - maxCount?: number; - from?: string; - to?: string; - file?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.getGitLog(directory, { maxCount, from, to, file }); - return { id, type, success: true, data: result }; - } - - case 'api:git/commit-files': { - const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; - if (!directory || !hash) { - return { id, type, success: false, error: 'Directory and hash are required' }; - } - const result = await gitService.getCommitFiles(directory, hash); - return { id, type, success: true, data: result }; - } - - case 'api:git/pr-description': { - const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as { - directory?: string; - base?: string; - head?: string; - context?: string; - providerId?: string; - modelId?: string; - zenModel?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!base || !head) { - return { id, type, success: false, error: 'base and head are required' }; - } - - // Collect diffs (best-effort) - let files: string[] = []; - try { - const listed = await gitService.getGitRangeFiles(directory, base, head); - files = Array.isArray(listed) ? listed : []; - } catch { - files = []; - } - - if (files.length === 0) { - return { id, type, success: false, error: 'No diffs available for base...head' }; - } - - let diffSummaries = ''; - for (const file of files) { - try { - const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3); - const raw = typeof diff?.diff === 'string' ? diff.diff : ''; - if (!raw.trim()) continue; - diffSummaries += `FILE: ${file}\n${raw}\n\n`; - } catch { - // ignore - } - } - - if (!diffSummaries.trim()) { - return { id, type, success: false, error: 'No diffs available for selected files' }; - } - - const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`; - - try { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - return { id, type, success: false, error: 'OpenCode API unavailable' }; - } - - const settings = readSettings(ctx) as Record; - const { providerID, modelID } = await resolveBridgeGitGenerationModel( - { providerId, modelId, zenModel: payloadZenModel }, - settings, - apiUrl, - ctx?.manager?.getOpenCodeAuthHeaders() - ); - const raw = await generateBridgeTextWithSessionFlow({ - apiUrl, - directory, - prompt, - providerID, - modelID, - authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(), - }); - if (!raw) { - return { id, type, success: false, error: 'No PR description returned by generator' }; - } - - const cleaned = String(raw) - .trim() - .replace(/^```json\s*/i, '') - .replace(/^```\s*/i, '') - .replace(/```\s*$/i, '') - .trim(); - - const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw); - if (parsed) { - const title = typeof parsed.title === 'string' ? parsed.title : ''; - const body = typeof parsed.body === 'string' ? parsed.body : ''; - return { id, type, success: true, data: { title, body } }; - } - - return { id, type, success: true, data: { title: '', body: String(raw) } }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:git/identity': { - const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { - directory?: string; - method?: string; - userName?: string; - userEmail?: string; - sshKey?: string | null; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const identity = await gitService.getCurrentGitIdentity(directory); - return { id, type, success: true, data: identity }; - } - - if (normalizedMethod === 'POST') { - if (!userName || !userEmail) { - return { id, type, success: false, error: 'userName and userEmail are required' }; - } - const result = await gitService.setGitIdentity(directory, userName, userEmail, sshKey); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/ignore-openchamber': { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - // This is now a no-op since the function was removed with legacy worktree support. - return { id, type, success: true, data: { success: true } }; - } - - case 'api:git/conflict-details': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - try { - // Get git status --porcelain - const statusResult = await execGit(['status', '--porcelain'], directory); - const statusPorcelain = statusResult.stdout; - - // Get unmerged files (files with conflicts) - const unmergedResult = await execGit(['diff', '--name-only', '--diff-filter=U'], directory); - const unmergedFiles = unmergedResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - // Get current diff - const diffResult = await execGit(['diff'], directory); - const diff = diffResult.stdout; - - // Detect operation type and get head info - let operation: 'merge' | 'rebase' = 'merge'; - let headInfo = ''; - - // Check for MERGE_HEAD (merge in progress) - const mergeHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory); - const mergeHeadExists = mergeHeadResult.exitCode === 0; - - if (mergeHeadExists) { - operation = 'merge'; - const mergeHead = mergeHeadResult.stdout.trim(); - // Try to read MERGE_MSG file - let mergeMsg = ''; - try { - const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG'); - mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8'); - } catch { - // MERGE_MSG may not exist - } - headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`; - } else { - // Check for REBASE_HEAD (rebase in progress) - const rebaseHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory); - const rebaseHeadExists = rebaseHeadResult.exitCode === 0; - - if (rebaseHeadExists) { - operation = 'rebase'; - const rebaseHead = rebaseHeadResult.stdout.trim(); - headInfo = `REBASE_HEAD: ${rebaseHead}`; - } - } - - return { - id, - type, - success: true, - data: { - statusPorcelain: statusPorcelain.trim(), - unmergedFiles, - diff: diff.trim(), - headInfo: headInfo.trim(), - operation, - }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } + return { id, type, success: false, error: GITHUB_BACKEND_DISABLED_ERROR }; } default: diff --git a/packages/vscode/src/opencode-ready.ts b/packages/vscode/src/opencode-ready.ts new file mode 100644 index 00000000..b7f721da --- /dev/null +++ b/packages/vscode/src/opencode-ready.ts @@ -0,0 +1,59 @@ +import type { OpenCodeManager } from './opencode'; + +export const API_URL_WAIT_TIMEOUT_MS = 30000; + +export async function waitForApiUrl( + manager: OpenCodeManager | undefined, + timeoutMs = API_URL_WAIT_TIMEOUT_MS, +): Promise { + if (!manager) { + return null; + } + + const initialUrl = manager.getApiUrl(); + if (initialUrl) { + return initialUrl; + } + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + let subscription: { dispose(): void } | null = null; + let disposeAfterSubscribe = false; + + const handleStatusChange = () => { + const nextUrl = manager.getApiUrl(); + if (!nextUrl || settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (subscription) { + subscription.dispose(); + } else { + disposeAfterSubscribe = true; + } + resolve(nextUrl); + }; + + subscription = manager.onStatusChange(handleStatusChange); + if (disposeAfterSubscribe) { + subscription.dispose(); + return; + } + if (settled) { + return; + } + + timeoutId = setTimeout(() => { + if (settled) { + return; + } + settled = true; + subscription?.dispose(); + resolve(manager.getApiUrl()); + }, timeoutMs); + }); +} diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index da10c2a1..48bc8696 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -64,6 +64,14 @@ const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void } }; +export const getSessionActivitySnapshot = (): Record => { + const snapshot: Record = {}; + for (const [sessionId, data] of sessionActivityPhases.entries()) { + snapshot[sessionId] = { type: data.phase }; + } + return snapshot; +}; + const deriveSessionActivity = (payload: Record): SessionActivity | null => { if (!payload || typeof payload !== 'object') { return null; diff --git a/packages/vscode/src/sseProxy.ts b/packages/vscode/src/sseProxy.ts index b757a8b4..67826e56 100644 --- a/packages/vscode/src/sseProxy.ts +++ b/packages/vscode/src/sseProxy.ts @@ -1,5 +1,6 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { OpenCodeManager } from './opencode'; +import { waitForApiUrl } from './opencode-ready'; type StreamEvent = { data: TData; @@ -55,8 +56,8 @@ const resolveDefaultDirectory = (manager: OpenCodeManager): string => { return manager.getWorkingDirectory() || 'global'; }; -const createAuthedClient = (manager: OpenCodeManager, headers?: Record) => { - const baseUrl = manager.getApiUrl(); +const createAuthedClient = async (manager: OpenCodeManager, headers?: Record) => { + const baseUrl = await waitForApiUrl(manager); if (!baseUrl) { throw new Error('OpenCode API URL not available'); } @@ -98,7 +99,7 @@ export const openSseProxy = async ({ signal, onChunk, }: OpenSseProxyOptions): Promise => { - const client = createAuthedClient(manager, headers); + const client = await createAuthedClient(manager, headers); const { pathname, directory } = normalizeSsePath(path); const resolvedDirectory = directory || resolveDefaultDirectory(manager); diff --git a/packages/vscode/webview/api/streamPerf.ts b/packages/vscode/webview/api/streamPerf.ts new file mode 100644 index 00000000..460702f9 --- /dev/null +++ b/packages/vscode/webview/api/streamPerf.ts @@ -0,0 +1,88 @@ +const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf'; + +type PerfCounter = { + count: number; + total: number; + max: number; + last: number; +}; + +type StreamPerfState = { + counters: Map; + startedAt: number; + lastUpdatedAt: number; +}; + +declare global { + interface Window { + __openchamberVsCodeStreamPerfState__?: StreamPerfState; + } +} + +export const vscodeStreamPerfEnabled = (): boolean => { + try { + return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const nowMs = (): number => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +}; + +const ensurePerfState = (): StreamPerfState | null => { + if (!vscodeStreamPerfEnabled()) { + return null; + } + + if (!window.__openchamberVsCodeStreamPerfState__) { + const startedAt = Date.now(); + window.__openchamberVsCodeStreamPerfState__ = { + counters: new Map(), + startedAt, + lastUpdatedAt: startedAt, + }; + } + + return window.__openchamberVsCodeStreamPerfState__; +}; + +const updateCounter = (metric: string, amount: number): void => { + const state = ensurePerfState(); + if (!state) { + return; + } + + const bucket = state.counters.get(metric) ?? { count: 0, total: 0, max: 0, last: 0 }; + bucket.count += 1; + bucket.total += amount; + bucket.max = Math.max(bucket.max, amount); + bucket.last = amount; + state.counters.set(metric, bucket); + state.lastUpdatedAt = Date.now(); +}; + +export const vscodeStreamPerfCount = (metric: string, count = 1): void => { + updateCounter(metric, count); +}; + +export const vscodeStreamPerfObserve = (metric: string, value: number): void => { + updateCounter(metric, value); +}; + +export const vscodeStreamPerfMeasure = (metric: string, fn: () => T): T => { + if (!vscodeStreamPerfEnabled()) { + return fn(); + } + + const start = nowMs(); + try { + return fn(); + } finally { + updateCounter(metric, nowMs() - start); + } +}; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 753cd17f..f847af11 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,5 +1,6 @@ import { createVSCodeAPIs } from './api'; import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; +import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { buildVSCodeThemeFromPalette, @@ -132,15 +133,27 @@ let bootstrapFailed = false; const recordBootstrapFetch = (pathname: string, ok: boolean) => { if (!pathname.startsWith('/api/')) return; + // Don't mark as failed while still connecting — early 503s are expected + const isConnected = window.__OPENCHAMBER_CONNECTION__?.status === 'connected'; + if (pathname.startsWith('/api/config/providers')) { - if (ok) bootstrapProvidersReady = true; - else bootstrapFailed = true; + if (ok) { + bootstrapProvidersReady = true; + // Reset failed flag — a successful retry supersedes earlier 503s + if (bootstrapAgentsReady || !isConnected) bootstrapFailed = false; + } else if (isConnected) { + bootstrapFailed = true; + } return; } if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) { - if (ok) bootstrapAgentsReady = true; - else bootstrapFailed = true; + if (ok) { + bootstrapAgentsReady = true; + if (bootstrapProvidersReady || !isConnected) bootstrapFailed = false; + } else if (isConnected) { + bootstrapFailed = true; + } } }; @@ -395,14 +408,20 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const method = ((init?.method || 'GET') as string).toUpperCase(); if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { - return new Response(JSON.stringify({ - statusSessions: {}, - attentionSessions: {}, - serverTime: Date.now(), - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + const activity = await sendBridgeMessage>('api:session-activity:get') + .catch(() => ({})); + return new Response( + JSON.stringify({ + statusSessions: {}, + attentionSessions: {}, + activitySessions: activity || {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); } if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') { @@ -412,6 +431,84 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } + if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + success: true, + sessionId, + messageSent: true, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (normalizedPathname === '/api/session-activity' && method === 'GET') { + const activity = await sendBridgeMessage>('api:session-activity:get') + .catch(() => ({})); + return new Response(JSON.stringify(activity || {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (normalizedPathname === '/api/sessions/status' && method === 'GET') { + return new Response( + JSON.stringify({ + sessions: {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (normalizedPathname === '/api/sessions/attention' && method === 'GET') { + return new Response( + JSON.stringify({ + sessions: {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + error: 'Session not found or no state available', + sessionId, + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + error: 'Session not found or no attention state available', + sessionId, + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + if (normalizedPathname === '/api/tts/status' && method === 'GET') { return new Response(JSON.stringify({ available: false }), { status: 200, @@ -734,6 +831,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (pathname === '/api/config/opencode-resolution' && method === 'GET') { + try { + const data = await sendBridgeMessage('api:config/opencode-resolution:get'); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname.startsWith('/api/config/reload')) { await sendBridgeMessage('api:config/reload'); return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -749,6 +856,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } } + if (pathname === '/api/zen/models' && method === 'GET') { + try { + const data = await sendBridgeMessage('api:zen:models'); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname.startsWith('/api/openchamber/update-check')) { try { const currentVersion = url.searchParams.get('currentVersion') || undefined; @@ -816,8 +933,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') { const providerId = decodeURIComponent(providerAuthMatch[1]); const scope = url.searchParams.get('scope') || 'auth'; + const queryDirectory = url.searchParams.get('directory') || undefined; try { - const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope }); + const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -829,8 +947,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/); if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') { const providerId = decodeURIComponent(providerSourceMatch[1]); + const queryDirectory = url.searchParams.get('directory') || undefined; try { - const data = await sendBridgeMessage('api:provider/source:get', { providerId }); + const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -877,7 +996,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const headers = { ...headersFromRequest, ...headersFromInit }; if (isSseApiPath(targetUrl.pathname)) { - const start = await startSseProxy({ path: suffixPath, headers }); + const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers })); if (!start.streamId) { return new Response(null, { status: start.status || 503, headers: start.headers || {} }); } @@ -894,11 +1013,14 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!msg || msg.streamId !== streamId) return; if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') { + vscodeStreamPerfCount('vscode.webview.sse_chunk'); + vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length); controller.enqueue(encoder.encode(msg.chunk)); return; } if (msg.type === 'api:sse:end') { + vscodeStreamPerfCount('vscode.webview.sse_end'); unsubscribe?.(); unsubscribe = null; if (typeof msg.error === 'string' && msg.error.length > 0) { @@ -974,11 +1096,9 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { // Listen for addToContext command from extension onCommand('addToContext', (payload) => { const { text } = payload as { text: string }; - - // Import the store dynamically to avoid circular dependencies - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.setPendingInputText(text, 'append'); + + import('@/sync/input-store').then(({ useInputStore }) => { + useInputStore.getState().setPendingInputText(text, 'append'); }); }); @@ -997,29 +1117,29 @@ onCommand('addFileMentions', (payload) => { const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' '); - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.setPendingInputText(mentionText, 'append-inline'); + import('@/sync/input-store').then(({ useInputStore }) => { + useInputStore.getState().setPendingInputText(mentionText, 'append-inline'); }); }); // Listen for createSessionWithPrompt command from extension (Explain, Improve Code) onCommand('createSessionWithPrompt', (payload) => { const { prompt } = payload as { prompt: string }; - + Promise.all([ - import('@/stores/useSessionStore'), + import('@/sync/session-ui-store'), import('@/stores/useConfigStore'), - ]).then(([{ useSessionStore }, { useConfigStore }]) => { - const sessionStore = useSessionStore.getState(); + import('@/sync/input-store'), + ]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => { + const sessionStore = useSessionUIStore.getState(); const configStore = useConfigStore.getState(); - + // Open a new session draft first sessionStore.openNewSessionDraft(); - + // Get current provider/model/agent configuration const { currentProviderId, currentModelId, currentAgentName } = configStore; - + if (currentProviderId && currentModelId) { // Send the message - this will create the session from the draft and send sessionStore.sendMessage( @@ -1035,16 +1155,15 @@ onCommand('createSessionWithPrompt', (payload) => { }); } else { // If no provider/model configured, just set the text and let user send manually - sessionStore.setPendingInputText(prompt); + useInputStore.getState().setPendingInputText(prompt); } }); }); // Listen for newSession command from extension title bar button onCommand('newSession', () => { - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.openNewSessionDraft(); + import('@/sync/session-ui-store').then(({ useSessionUIStore }) => { + useSessionUIStore.getState().openNewSessionDraft(); }); // Also dispatch event to navigate to chat view in VSCodeLayout diff --git a/packages/web/package.json b/packages/web/package.json index 73e96d64..ffde089d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,7 +29,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6e217c3e..976367a0 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -4,18 +4,15 @@ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import http from 'http'; import net from 'net'; -import { WebSocketServer } from 'ws'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; import { createUiAuth } from './lib/opencode/ui-auth.js'; import { createTunnelAuth } from './lib/opencode/tunnel-auth.js'; -import { - printTunnelWarning, -} from './lib/cloudflare-tunnel.js'; -import { createTunnelService } from './lib/tunnels/index.js'; +import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js'; import { createTunnelProviderRegistry } from './lib/tunnels/registry.js'; import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js'; +import { createRequestSecurityRuntime } from './lib/security/request-security.js'; import { TUNNEL_MODE_MANAGED_LOCAL, TUNNEL_MODE_MANAGED_REMOTE, @@ -29,16 +26,45 @@ import { normalizeTunnelProvider, } from './lib/tunnels/types.js'; import { prepareNotificationLastMessage } from './lib/notifications/index.js'; +import { registerTtsRoutes } from './lib/tts/routes.js'; +import { detectSayTtsCapability } from './lib/tts/capability-runtime.js'; +import { createTerminalRuntime } from './lib/terminal/runtime.js'; +import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js'; +import { createOpenCodeLifecycleRuntime } from './lib/opencode/lifecycle.js'; +import { createOpenCodeEnvRuntime } from './lib/opencode/env-runtime.js'; +import { resolveOpenCodeEnvConfig } from './lib/opencode/env-config.js'; +import { createHmrStateRuntime } from './lib/opencode/hmr-state-runtime.js'; +import { createOpenCodeNetworkRuntime } from './lib/opencode/network-runtime.js'; +import { createOpenCodeAuthStateRuntime } from './lib/opencode/auth-state-runtime.js'; +import { createProjectDirectoryRuntime } from './lib/opencode/project-directory-runtime.js'; +import { createSettingsNormalizationRuntime } from './lib/opencode/settings-normalization-runtime.js'; +import { createSettingsHelpers } from './lib/opencode/settings-helpers.js'; +import { createThemeRuntime } from './lib/opencode/theme-runtime.js'; +import { createFeatureRoutesRuntime } from './lib/opencode/feature-routes-runtime.js'; +import { parseServeCliOptions } from './lib/opencode/cli-options.js'; import { - TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - TERMINAL_INPUT_WS_PATH, - createTerminalInputWsControlFrame, - isRebindRateLimited, - normalizeTerminalInputWsMessageToText, - parseRequestPathname, - pruneRebindTimestamps, - readTerminalInputWsControlFrame, -} from './lib/terminal/index.js'; + registerAuthAndAccessRoutes, + registerCommonRequestMiddleware, + registerServerStatusRoutes, +} from './lib/opencode/core-routes.js'; +import { registerOpenChamberRoutes } from './lib/opencode/openchamber-routes.js'; +import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js'; +import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js'; +import { createSettingsRuntime } from './lib/opencode/settings-runtime.js'; +import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js'; +import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; +import { createSessionRuntime } from './lib/opencode/session-runtime.js'; +import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; +import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; +import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js'; +import { createStartupPipelineRuntime } from './lib/opencode/startup-pipeline-runtime.js'; +import { runCliEntryIfMain } from './lib/opencode/cli-entry-runtime.js'; +import { registerNotificationRoutes } from './lib/notifications/routes.js'; +import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js'; +import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js'; +import { createPushRuntime } from './lib/notifications/push-runtime.js'; +import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; +import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import webPush from 'web-push'; const __filename = fileURLToPath(import.meta.url); @@ -73,1189 +99,70 @@ const OPENCHAMBER_VERSION = (() => { return 'unknown'; })(); const fsPromises = fs.promises; -const FILE_SEARCH_MAX_CONCURRENCY = 5; -const FILE_SEARCH_EXCLUDED_DIRS = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - '.turbo', - '.cache', - 'coverage', - 'tmp', - 'logs' -]); -// Lock to prevent race conditions in persistSettings -let persistSettingsLock = Promise.resolve(); +const settingsNormalizationRuntime = createSettingsNormalizationRuntime({ + os, + path, + processLike: process, + tunnelBootstrapTtlDefaultMs: TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS, + tunnelBootstrapTtlMinMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, + tunnelBootstrapTtlMaxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, + tunnelSessionTtlDefaultMs: TUNNEL_SESSION_TTL_DEFAULT_MS, + tunnelSessionTtlMinMs: TUNNEL_SESSION_TTL_MIN_MS, + tunnelSessionTtlMaxMs: TUNNEL_SESSION_TTL_MAX_MS, +}); -const normalizeDirectoryPath = (value) => { - if (typeof value !== 'string') { - return value; - } - - const trimmed = value.trim(); - if (!trimmed) { - return trimmed; - } - - if (trimmed === '~') { - return os.homedir(); - } - - if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { - return path.join(os.homedir(), trimmed.slice(2)); - } - - return trimmed; -}; - -const normalizePathForPersistence = (value) => { - if (typeof value !== 'string') { - return value; - } - - const normalized = normalizeDirectoryPath(value); - if (typeof normalized !== 'string') { - return normalized; - } - - const trimmed = normalized.trim(); - if (!trimmed) { - return trimmed; - } - - if (process.platform !== 'win32') { - return trimmed; - } - - return trimmed.replace(/\//g, '\\'); -}; - -const areStringArraysEqual = (a, b) => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -}; - -const normalizeSettingsPaths = (input) => { - const settings = input && typeof input === 'object' ? input : {}; - let next = settings; - let changed = false; - - const ensureNext = () => { - if (next === settings) { - next = { ...settings }; - } - }; - - const normalizePathField = (key) => { - if (typeof settings[key] !== 'string' || settings[key].length === 0) { - return; - } - const normalized = normalizePathForPersistence(settings[key]); - if (normalized !== settings[key]) { - ensureNext(); - next[key] = normalized; - changed = true; - } - }; - - const normalizePathArrayField = (key) => { - if (!Array.isArray(settings[key])) { - return; - } - - const normalized = normalizeStringArray( - settings[key] - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - - if (!areStringArraysEqual(normalized, settings[key])) { - ensureNext(); - next[key] = normalized; - changed = true; - } - }; - - normalizePathField('lastDirectory'); - normalizePathField('homeDirectory'); - normalizePathArrayField('approvedDirectories'); - normalizePathArrayField('pinnedDirectories'); - - if (Array.isArray(settings.projects)) { - const normalizedProjects = sanitizeProjects(settings.projects) || []; - if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) { - ensureNext(); - next.projects = normalizedProjects; - changed = true; - } - } - - return { settings: next, changed }; -}; +const normalizeDirectoryPath = (...args) => settingsNormalizationRuntime.normalizeDirectoryPath(...args); +const normalizePathForPersistence = (...args) => settingsNormalizationRuntime.normalizePathForPersistence(...args); +const normalizeSettingsPaths = (...args) => settingsNormalizationRuntime.normalizeSettingsPaths(...args); +const normalizeTunnelBootstrapTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelBootstrapTtlMs(...args); +const normalizeTunnelSessionTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelSessionTtlMs(...args); +const normalizeManagedRemoteTunnelHostname = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelHostname(...args); +const normalizeManagedRemoteTunnelPresets = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresets(...args); +const normalizeManagedRemoteTunnelPresetTokens = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresetTokens(...args); +const isUnsafeSkillRelativePath = (...args) => settingsNormalizationRuntime.isUnsafeSkillRelativePath(...args); +const sanitizeTypographySizesPartial = (...args) => + settingsNormalizationRuntime.sanitizeTypographySizesPartial(...args); +const normalizeStringArray = (...args) => settingsNormalizationRuntime.normalizeStringArray(...args); +const sanitizeModelRefs = (...args) => settingsNormalizationRuntime.sanitizeModelRefs(...args); +const sanitizeSkillCatalogs = (...args) => settingsNormalizationRuntime.sanitizeSkillCatalogs(...args); +const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProjects(...args); const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber'); const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes'); const MAX_THEME_JSON_BYTES = 512 * 1024; -const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; -const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); - -const normalizeTunnelBootstrapTtlMs = (value) => { - if (value === null) { - return null; - } - if (!Number.isFinite(value)) { - return TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS; - } - return clampNumber(Math.round(value), TUNNEL_BOOTSTRAP_TTL_MIN_MS, TUNNEL_BOOTSTRAP_TTL_MAX_MS); -}; - -const normalizeTunnelSessionTtlMs = (value) => { - if (!Number.isFinite(value)) { - return TUNNEL_SESSION_TTL_DEFAULT_MS; - } - return clampNumber(Math.round(value), TUNNEL_SESSION_TTL_MIN_MS, TUNNEL_SESSION_TTL_MAX_MS); -}; - -const normalizeManagedRemoteTunnelHostname = (value) => { - if (typeof value !== 'string') { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const parsed = (() => { - try { - if (trimmed.includes('://')) { - return new URL(trimmed); - } - return new URL(`https://${trimmed}`); - } catch { - return null; - } - })(); - - const hostname = parsed?.hostname?.trim().toLowerCase() || ''; - if (!hostname) { - return undefined; - } - return hostname; -}; - -const normalizeManagedRemoteTunnelPresets = (value) => { - if (!Array.isArray(value)) { - return undefined; - } - - const result = []; - const seenIds = new Set(); - const seenHostnames = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; - const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname); - if (!id || !name || !hostname) continue; - if (seenIds.has(id) || seenHostnames.has(hostname)) continue; - seenIds.add(id); - seenHostnames.add(hostname); - result.push({ id, name, hostname }); - } - - return result; -}; - -const normalizeManagedRemoteTunnelPresetTokens = (value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - - const result = {}; - for (const [rawId, rawToken] of Object.entries(value)) { - const id = typeof rawId === 'string' ? rawId.trim() : ''; - const token = typeof rawToken === 'string' ? rawToken.trim() : ''; - if (!id || !token) { - continue; - } - result[id] = token; - } - - return Object.keys(result).length > 0 ? result : undefined; -}; - -const isValidThemeColor = (value) => isNonEmptyString(value); - -const normalizeThemeJson = (raw) => { - if (!raw || typeof raw !== 'object') { - return null; - } - - const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null; - const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null; - if (!metadata || !colors) { - return null; - } - - const id = metadata.id; - const name = metadata.name; - const variant = metadata.variant; - if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) { - return null; - } - - const primary = colors.primary; - const surface = colors.surface; - const interactive = colors.interactive; - const status = colors.status; - const syntax = colors.syntax; - const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null; - const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null; - - if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) { - return null; - } - - // Minimal fields required by CSSVariableGenerator and diff/syntax rendering. - const required = [ - primary.base, - primary.foreground, - surface.background, - surface.foreground, - surface.muted, - surface.mutedForeground, - surface.elevated, - surface.elevatedForeground, - surface.subtle, - interactive.border, - interactive.selection, - interactive.selectionForeground, - interactive.focusRing, - interactive.hover, - status.error, - status.errorForeground, - status.errorBackground, - status.errorBorder, - status.warning, - status.warningForeground, - status.warningBackground, - status.warningBorder, - status.success, - status.successForeground, - status.successBackground, - status.successBorder, - status.info, - status.infoForeground, - status.infoBackground, - status.infoBorder, - syntaxBase.background, - syntaxBase.foreground, - syntaxBase.keyword, - syntaxBase.string, - syntaxBase.number, - syntaxBase.function, - syntaxBase.variable, - syntaxBase.type, - syntaxBase.comment, - syntaxBase.operator, - syntaxHighlights.diffAdded, - syntaxHighlights.diffRemoved, - syntaxHighlights.lineNumber, - ]; - - if (!required.every(isValidThemeColor)) { - return null; - } - - const tags = Array.isArray(metadata.tags) - ? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0) - : []; - - return { - ...raw, - metadata: { - ...metadata, - id: id.trim(), - name: name.trim(), - description: typeof metadata.description === 'string' ? metadata.description : '', - version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0', - variant, - tags, - }, - }; -}; - -const readCustomThemesFromDisk = async () => { - try { - const entries = await fsPromises.readdir(OPENCHAMBER_USER_THEMES_DIR, { withFileTypes: true }); - const themes = []; - const seen = new Set(); - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith('.json')) continue; - - const filePath = path.join(OPENCHAMBER_USER_THEMES_DIR, entry.name); - try { - const stat = await fsPromises.stat(filePath); - if (!stat.isFile()) continue; - if (stat.size > MAX_THEME_JSON_BYTES) { - console.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`); - continue; - } - - const rawText = await fsPromises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(rawText); - const normalized = normalizeThemeJson(parsed); - if (!normalized) { - console.warn(`[themes] Skip ${entry.name}: invalid theme JSON`); - continue; - } - - const id = normalized.metadata.id; - if (seen.has(id)) { - console.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`); - continue; - } - - seen.add(id); - themes.push(normalized); - } catch (error) { - console.warn(`[themes] Failed to read ${entry.name}:`, error); - } - } - - return themes; - } catch (error) { - // Missing dir is fine. - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return []; - } - console.warn('[themes] Failed to list custom themes dir:', error); - return []; - } -}; - -const isPathWithinRoot = (resolvedPath, rootPath) => { - const resolvedRoot = path.resolve(rootPath || os.homedir()); - const relative = path.relative(resolvedRoot, resolvedPath); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - return false; - } - return true; -}; - -const resolveWorkspacePath = (targetPath, baseDirectory) => { - const normalized = normalizeDirectoryPath(targetPath); - if (!normalized || typeof normalized !== 'string') { - return { ok: false, error: 'Path is required' }; - } - - const resolved = path.resolve(normalized); - const resolvedBase = path.resolve(baseDirectory || os.homedir()); - - if (isPathWithinRoot(resolved, resolvedBase)) { - return { ok: true, base: resolvedBase, resolved }; - } - - // Allow writing OpenChamber per-project config under ~/.config/openchamber. - // LEGACY_PROJECT_CONFIG: migration target root; allowed outside workspace. - if (isPathWithinRoot(resolved, OPENCHAMBER_USER_CONFIG_ROOT)) { - return { ok: true, base: path.resolve(OPENCHAMBER_USER_CONFIG_ROOT), resolved }; - } - - return { ok: false, error: 'Path is outside of active workspace' }; -}; - -const resolveWorkspacePathFromWorktrees = async (targetPath, baseDirectory) => { - const normalized = normalizeDirectoryPath(targetPath); - if (!normalized || typeof normalized !== 'string') { - return { ok: false, error: 'Path is required' }; - } - - const resolved = path.resolve(normalized); - const resolvedBase = path.resolve(baseDirectory || os.homedir()); - - try { - const { getWorktrees } = await import('./lib/git/index.js'); - const worktrees = await getWorktrees(resolvedBase); - - for (const worktree of worktrees) { - const candidatePath = typeof worktree?.path === 'string' - ? worktree.path - : (typeof worktree?.worktree === 'string' ? worktree.worktree : ''); - const candidate = normalizeDirectoryPath(candidatePath); - if (!candidate) { - continue; - } - const candidateResolved = path.resolve(candidate); - if (isPathWithinRoot(resolved, candidateResolved)) { - return { ok: true, base: candidateResolved, resolved }; - } - } - } catch (error) { - console.warn('Failed to resolve worktree roots:', error); - } - - return { ok: false, error: 'Path is outside of active workspace' }; -}; - -const resolveWorkspacePathFromContext = async (req, targetPath) => { - const resolvedProject = await resolveProjectDirectory(req); - if (!resolvedProject.directory) { - return { ok: false, error: resolvedProject.error || 'Active workspace is required' }; - } - - const resolved = resolveWorkspacePath(targetPath, resolvedProject.directory); - if (resolved.ok || resolved.error !== 'Path is outside of active workspace') { - return resolved; - } - - return resolveWorkspacePathFromWorktrees(targetPath, resolvedProject.directory); -}; - - -const normalizeRelativeSearchPath = (rootPath, targetPath) => { - const relative = path.relative(rootPath, targetPath) || path.basename(targetPath); - return relative.split(path.sep).join('/') || targetPath; -}; - -const shouldSkipSearchDirectory = (name, includeHidden) => { - if (!name) { - return false; - } - if (!includeHidden && name.startsWith('.')) { - return true; - } - return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); -}; - -const listDirectoryEntries = async (dirPath) => { - try { - return await fsPromises.readdir(dirPath, { withFileTypes: true }); - } catch { - return []; - } -}; - -/** - * Fuzzy match scoring function. - * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. - * Higher scores indicate better matches. - */ -const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => { - if (!normalizedQuery) return 0; - - const q = normalizedQuery; - const c = candidate.toLowerCase(); - - // Fast path: exact substring match gets high score - if (c.includes(q)) { - const idx = c.indexOf(q); - // Bonus for match at start or after word boundary - let bonus = 0; - if (idx === 0) { - bonus = 20; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - bonus = 15; - } - } - return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); - } - - // Fuzzy match: all query chars must appear in order - let score = 0; - let lastIndex = -1; - let consecutive = 0; - - for (let i = 0; i < q.length; i++) { - const ch = q[i]; - if (!ch || ch === ' ') continue; - - const idx = c.indexOf(ch, lastIndex + 1); - if (idx === -1) { - return null; // No match - } - - const gap = idx - lastIndex - 1; - if (gap === 0) { - consecutive++; - } else { - consecutive = 0; - } - - score += 10; - score += Math.max(0, 18 - idx); // Prefer matches near start - score -= Math.min(gap, 10); // Penalize gaps - - // Bonus for word boundary matches - if (idx === 0) { - score += 12; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - score += 10; - } - } - - score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches - lastIndex = idx; - } - - // Prefer shorter paths - score += Math.max(0, 24 - Math.floor(c.length / 3)); - - return score; -}; - -const searchFilesystemFiles = async (rootPath, options) => { - const { limit, query, includeHidden, respectGitignore } = options; - const includeHiddenEntries = Boolean(includeHidden); - const normalizedQuery = query.trim().toLowerCase(); - const matchAll = normalizedQuery.length === 0; - const queue = [rootPath]; - const visited = new Set([rootPath]); - const shouldRespectGitignore = respectGitignore !== false; - // Collect more candidates for fuzzy matching, then sort and trim - const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); - const candidates = []; - - while (queue.length > 0 && candidates.length < collectLimit) { - const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); - - const dirResults = await Promise.all( - batch.map(async (dir) => { - if (!shouldRespectGitignore) { - return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; - } - - try { - const dirents = await listDirectoryEntries(dir); - const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean); - if (pathsToCheck.length === 0) { - return { dir, dirents, ignoredPaths: new Set() }; - } - - const result = await new Promise((resolve) => { - const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { - cwd: dir, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - child.stdout.on('data', (data) => { stdout += data.toString(); }); - child.on('close', () => resolve(stdout)); - child.on('error', () => resolve('')); - }); - - const ignoredNames = new Set( - String(result) - .split('\n') - .map((name) => name.trim()) - .filter(Boolean) - ); - - return { dir, dirents, ignoredPaths: ignoredNames }; - } catch { - return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; - } - }) - ); - - for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) { - for (const dirent of dirents) { - const entryName = dirent.name; - if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) { - continue; - } - - if (shouldRespectGitignore && ignoredPaths.has(entryName)) { - continue; - } - - const entryPath = path.join(currentDir, entryName); - - if (dirent.isDirectory()) { - if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) { - continue; - } - if (!visited.has(entryPath)) { - visited.add(entryPath); - queue.push(entryPath); - } - continue; - } - - if (!dirent.isFile()) { - continue; - } - - const relativePath = normalizeRelativeSearchPath(rootPath, entryPath); - const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; - - if (matchAll) { - candidates.push({ - name: entryName, - path: entryPath, - relativePath, - extension, - score: 0 - }); - } else { - // Try fuzzy match against relative path (includes filename) - const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath); - if (score !== null) { - candidates.push({ - name: entryName, - path: entryPath, - relativePath, - extension, - score - }); - } - } - - if (candidates.length >= collectLimit) { - queue.length = 0; - break; - } - } - - if (candidates.length >= collectLimit) { - break; - } - } - } - - // Sort by score descending, then by path length, then alphabetically - if (!matchAll) { - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.relativePath.length !== b.relativePath.length) { - return a.relativePath.length - b.relativePath.length; - } - return a.relativePath.localeCompare(b.relativePath); - }); - } - - // Return top results without the score field - return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ - name, - path: filePath, - relativePath, - extension - })); -}; - -const createTimeoutSignal = (timeoutMs) => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return { - signal: controller.signal, - cleanup: () => clearTimeout(timer), - }; -}; - -/** Humanize a project label: replace dashes/underscores with spaces, title-case each word. Mirrors the UI's formatProjectLabel. */ -const formatProjectLabel = (label) => { - if (!label || typeof label !== 'string') return ''; - return label - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()); -}; - -const resolveNotificationTemplate = (template, variables) => { - if (!template || typeof template !== 'string') return ''; - return template.replace(/\{(\w+)\}/g, (_match, key) => { - const value = variables[key]; - if (value === undefined || value === null) return ''; - return String(value); - }); -}; - -const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { - if (!resolved) { - return false; - } - - if (typeof template !== 'string') { - return true; - } - - if (template.includes('{last_message}')) { - return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0; - } - - return true; -}; - -const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; - -/** - * Validated fallback zen model determined at startup by checking available free - * models from the zen API. When `null`, startup validation hasn't run yet (or - * failed), so `resolveZenModel` falls back to `ZEN_DEFAULT_MODEL`. - */ -let validatedZenFallback = null; - -/** Cached free zen models response and timestamp (shared by startup + endpoint). */ -let cachedZenModels = null; -let cachedZenModelsTimestamp = 0; -const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -/** - * Fetch free models from the zen API with caching. Returns an array of - * `{ id, owned_by }` objects (may be empty on failure). Results are cached - * for `ZEN_MODELS_CACHE_TTL` ms. - */ -const fetchFreeZenModels = async () => { - const now = Date.now(); - if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { - return cachedZenModels.models; - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - try { - const response = await fetch('https://opencode.ai/zen/v1/models', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`zen/v1/models responded with status ${response.status}`); - } - const data = await response.json(); - const allModels = Array.isArray(data?.data) ? data.data : []; - const freeModels = allModels - .filter((m) => typeof m?.id === 'string' && m.id.endsWith('-free')) - .map((m) => ({ id: m.id, owned_by: m.owned_by })); - - cachedZenModels = { models: freeModels }; - cachedZenModelsTimestamp = Date.now(); - return freeModels; - } finally { - if (timeout) clearTimeout(timeout); - } -}; - -/** - * Resolve the zen model to use. Checks the provided override first, - * then falls back to the stored zenModel setting, then to the validated - * startup fallback, then to the hardcoded default. - */ -const resolveZenModel = async (override) => { - if (typeof override === 'string' && override.trim().length > 0) { - return override.trim(); - } - try { - const settings = await readSettingsFromDisk(); - if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { - return settings.zenModel.trim(); - } - } catch { - // ignore - } - return validatedZenFallback || ZEN_DEFAULT_MODEL; -}; - -const validateZenModelAtStartup = async () => { - try { - const freeModels = await fetchFreeZenModels(); - const freeModelIds = freeModels.map((m) => m.id); - - if (freeModelIds.length > 0) { - validatedZenFallback = freeModelIds[0]; - - const settings = await readSettingsFromDisk(); - const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; - - if (!storedModel || !freeModelIds.includes(storedModel)) { - const fallback = freeModelIds[0]; - console.log( - storedModel - ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` - : `[zen] No model configured, setting default to "${fallback}"` - ); - await persistSettings({ zenModel: fallback }); - } else { - console.log(`[zen] Stored model "${storedModel}" verified as available`); - } - } else { - console.warn('[zen] No free models returned from API, skipping validation'); - } - } catch (error) { - console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); - } -}; - - -const summarizeText = async (text, targetLength, zenModel) => { - if (!text || typeof text !== 'string' || text.trim().length === 0) return text; - - try { - const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`; - - const completionTimeout = createTimeoutSignal(15000); - let response; - try { - response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel || ZEN_DEFAULT_MODEL, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1000, - stream: false, - reasoning: { effort: 'low' }, - }), - signal: completionTimeout.signal, - }); - } finally { - completionTimeout.cleanup(); - } - - if (!response.ok) return text; - - const data = await response.json(); - const summary = data?.output?.find((item) => item?.type === 'message') - ?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); - - return summary || text; - } catch { - return text; - } -}; - -const NOTIFICATION_BODY_MAX_CHARS = 1000; - -/** - * Extract text from parts array (used when parts are available inline or fetched from API). - */ -const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - if (!Array.isArray(parts) || parts.length === 0) return ''; - - const textParts = parts - .filter((p) => p && (p.type === 'text' || typeof p.text === 'string' || typeof p.content === 'string')) - .map((p) => p.text || p.content || '') - .filter(Boolean); - - let text = textParts.length > 0 ? textParts.join('\n').trim() : ''; - - // Truncate to prevent oversized notification payloads - if (maxLength > 0 && text.length > maxLength) { - text = text.slice(0, maxLength); - } - - return text; -}; - -/** - * Try to extract message text from the payload itself (fast path). - * Note: message.updated events from the OpenCode SSE stream typically do NOT include - * parts inline — parts are sent via separate message.part.updated events. This function - * is a fast path for the rare case where parts are included. - */ -const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - const info = payload?.properties?.info; - if (!info) return ''; - - // Try inline parts on info or on properties - const parts = info.parts || payload?.properties?.parts; - const text = extractTextFromParts(parts, maxLength); - if (text) return text; - - // Fallback: try content array (legacy) - const content = info.content; - if (Array.isArray(content)) { - const textContent = content - .filter((c) => c && (c.type === 'text' || typeof c.text === 'string')) - .map((c) => c.text || '') - .filter(Boolean); - if (textContent.length > 0) { - let result = textContent.join('\n').trim(); - if (maxLength > 0 && result.length > maxLength) { - result = result.slice(0, maxLength); - } - return result; - } - } - - return ''; -}; - -/** - * Fetch the last assistant message text from the OpenCode API. - * This is needed because message.updated events don't include parts; - * we must fetch them separately via the session messages endpoint. - */ -const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - if (!sessionId) return ''; - - try { - // Fetch last few messages to find the one that triggered the notification - const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); - const response = await fetch(`${url}?limit=5`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(3000), - }); - - if (!response.ok) return ''; - - const messages = await response.json().catch(() => null); - if (!Array.isArray(messages)) return ''; - - // Find the specific message by ID, or fall back to the last assistant message - let target = null; - if (messageId) { - target = messages.find((m) => m?.info?.id === messageId && m?.info?.role === 'assistant'); - } - if (!target) { - // Find the last assistant message with finish === 'stop' - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (m?.info?.role === 'assistant' && m?.info?.finish === 'stop') { - target = m; - break; - } - } - } - - if (!target || !Array.isArray(target.parts)) return ''; - - return extractTextFromParts(target.parts, maxLength); - } catch { - return ''; - } -}; - -/** - * In-memory cache of session titles populated from SSE session.updated / session.created events. - * This is the preferred source for session titles since it is populated passively and doesn't - * require a separate API call. - */ -const sessionTitleCache = new Map(); - -const cacheSessionTitle = (sessionId, title) => { - if (typeof sessionId === 'string' && sessionId.length > 0 && - typeof title === 'string' && title.length > 0) { - sessionTitleCache.set(sessionId, title); - } -}; - -const getCachedSessionTitle = (sessionId) => { - return sessionTitleCache.get(sessionId) ?? null; -}; - -/** - * Extract and cache session title from session.updated / session.created SSE events. - * Called by the global event watcher to passively maintain the title cache. - */ -const maybeCacheSessionInfoFromEvent = (payload) => { - if (!payload || typeof payload !== 'object') return; - const type = payload.type; - if (type !== 'session.updated' && type !== 'session.created') return; - const info = payload.properties?.info; - if (!info || typeof info !== 'object') return; - const sessionId = info.id; - const title = info.title; - cacheSessionTitle(sessionId, title); - // Also cache parentID from session events to ensure subtask detection works correctly - const parentID = info.parentID; - if (sessionId && parentID !== undefined) { - setCachedSessionParentId(sessionId, parentID); - } -}; - -/** - * Fetch session metadata (title, directory) from the OpenCode API. - * Cached for 60s per session to avoid repeated API calls. - */ -const sessionInfoCache = new Map(); -const SESSION_INFO_CACHE_TTL_MS = 60 * 1000; - -const fetchSessionInfo = async (sessionId) => { - if (!sessionId) return null; - - const cached = sessionInfoCache.get(sessionId); - if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) { - return cached.data; - } - - try { - const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); - const response = await fetch(url, { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(2000), - }); - if (!response.ok) { - console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`); - return null; - } - const data = await response.json().catch(() => null); - if (data && typeof data === 'object') { - sessionInfoCache.set(sessionId, { data, at: Date.now() }); - return data; - } - return null; - } catch (err) { - console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, err?.message || err); - return null; - } -}; - -const buildTemplateVariables = async (payload, sessionId) => { - const info = payload?.properties?.info || {}; - - // Session title — try inline payload, then SSE cache, then API fetch - let sessionTitle = payload?.properties?.sessionTitle || - payload?.properties?.session?.title || - (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || - ''; - - // Try the SSE-populated session title cache (filled from session.updated / session.created events) - if (!sessionTitle && sessionId) { - const cached = getCachedSessionTitle(sessionId); - if (cached) { - sessionTitle = cached; - } - } - - // Last resort: fetch session info from the API - let sessionInfo = null; - if (!sessionTitle && sessionId) { - sessionInfo = await fetchSessionInfo(sessionId); - if (sessionInfo && typeof sessionInfo.title === 'string') { - sessionTitle = sessionInfo.title; - // Populate the SSE cache so future notifications don't need an API call - cacheSessionTitle(sessionId, sessionTitle); - } - } - - // Agent name from mode or agent field (v2 has both mode and agent) - const agentName = (() => { - const mode = typeof info.agent === 'string' && info.agent.trim().length > 0 - ? info.agent.trim() - : (typeof info.mode === 'string' ? info.mode.trim() : ''); - if (!mode) return 'Agent'; - return mode.split(/[-_\s]+/).filter(Boolean) - .map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' '); - })(); - - // Model name — v2 has modelID directly on info, v1 user messages nest it under info.model.modelID - const modelName = (() => { - const raw = typeof info.modelID === 'string' ? info.modelID.trim() - : (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : ''); - if (!raw) return 'Assistant'; - return raw.split(/[-_]+/).filter(Boolean) - .map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); - })(); - - // Project name, branch, worktree — derived from multiple sources with fallbacks - let projectName = ''; - let branch = ''; - let worktreeDir = ''; - - // 1. Primary source: the message payload's path (always accurate for the session) - const infoPath = info.path; - if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) { - worktreeDir = infoPath.root; - } else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) { - worktreeDir = infoPath.cwd; - } - - // 2. Look up the user-facing project label from stored settings - try { - const settings = await readSettingsFromDisk(); - const projects = Array.isArray(settings.projects) ? settings.projects : []; - - if (worktreeDir) { - // Match the session directory against stored projects to find the label - const normalizedDir = worktreeDir.replace(/\/+$/, ''); - const matchedProject = projects.find((p) => { - if (!p || typeof p.path !== 'string') return false; - return p.path.replace(/\/+$/, '') === normalizedDir; - }); - if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) { - projectName = matchedProject.label.trim(); - } else { - // No label stored — derive from directory name - projectName = normalizedDir.split('/').filter(Boolean).pop() || ''; - } - } else { - // No directory from payload — fall back to active project - const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; - const activeProject = activeId ? projects.find((p) => p && p.id === activeId) : projects[0]; - if (activeProject) { - projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0 - ? activeProject.label.trim() - : typeof activeProject.path === 'string' - ? activeProject.path.split('/').pop() || '' - : ''; - worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : ''; - } - } - } catch { - // Settings read failed — derive from directory if available - if (worktreeDir && !projectName) { - projectName = worktreeDir.split('/').filter(Boolean).pop() || ''; - } - } - - // 3. Get branch from git - if (worktreeDir) { - try { - const { simpleGit } = await import('simple-git'); - const git = simpleGit({ - baseDir: worktreeDir, - spawnOptions: { windowsHide: true }, - binary: resolveGitBinaryForSpawn(), - }); - branch = await Promise.race([ - git.revparse(['--abbrev-ref', 'HEAD']), - new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)), - ]).catch(() => ''); - } catch { - // ignore — git may not be available - } - } - - return { - project_name: formatProjectLabel(projectName), - worktree: worktreeDir, - branch: typeof branch === 'string' ? branch.trim() : '', - session_name: sessionTitle, - agent_name: agentName, - model_name: modelName, - last_message: '', // Populated by caller - session_id: sessionId || '', - }; -}; +const themeRuntime = createThemeRuntime({ + fsPromises, + path, + themesDir: OPENCHAMBER_USER_THEMES_DIR, + maxThemeJsonBytes: MAX_THEME_JSON_BYTES, + logger: console, +}); + +const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromDisk(...args); + +let notificationTemplateRuntime = null; + +const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args); +const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args); +const resolveNotificationTemplate = (...args) => notificationTemplateRuntime.resolveNotificationTemplate(...args); +const shouldApplyResolvedTemplateMessage = (...args) => notificationTemplateRuntime.shouldApplyResolvedTemplateMessage(...args); +const fetchFreeZenModels = (...args) => notificationTemplateRuntime.fetchFreeZenModels(...args); +const resolveZenModel = (...args) => notificationTemplateRuntime.resolveZenModel(...args); +const validateZenModelAtStartup = (...args) => notificationTemplateRuntime.validateZenModelAtStartup(...args); +const summarizeText = (...args) => notificationTemplateRuntime.summarizeText(...args); +const extractTextFromParts = (...args) => notificationTemplateRuntime.extractTextFromParts(...args); +const extractLastMessageText = (...args) => notificationTemplateRuntime.extractLastMessageText(...args); +const fetchLastAssistantMessageText = (...args) => notificationTemplateRuntime.fetchLastAssistantMessageText(...args); +const maybeCacheSessionInfoFromEvent = (...args) => notificationTemplateRuntime.maybeCacheSessionInfoFromEvent(...args); +const buildTemplateVariables = (...args) => notificationTemplateRuntime.buildTemplateVariables(...args); +const getCachedZenModels = (...args) => notificationTemplateRuntime.getCachedZenModels(...args); const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) @@ -1265,2357 +172,148 @@ const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subsc const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1; -const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons'); -const PROJECT_ICON_MIME_TO_EXTENSION = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/svg+xml': 'svg', - 'image/webp': 'webp', - 'image/x-icon': 'ico', -}; -const PROJECT_ICON_EXTENSION_TO_MIME = Object.fromEntries( - Object.entries(PROJECT_ICON_MIME_TO_EXTENSION).map(([mime, ext]) => [ext, mime]) -); -const PROJECT_ICON_SUPPORTED_MIMES = new Set(Object.keys(PROJECT_ICON_MIME_TO_EXTENSION)); -const PROJECT_ICON_MAX_BYTES = 5 * 1024 * 1024; -const PROJECT_ICON_THEME_COLORS = { - light: '#111111', - dark: '#f5f5f5', -}; -const PROJECT_ICON_HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/; -const normalizeProjectIconMime = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'image/jpg') { - return 'image/jpeg'; - } - if (PROJECT_ICON_SUPPORTED_MIMES.has(normalized)) { - return normalized; - } - return null; -}; - -const projectIconBaseName = (projectId) => { - const hash = crypto.createHash('sha1').update(projectId).digest('hex'); - return `project-${hash}`; -}; - -const projectIconPathForMime = (projectId, mime) => { - const normalizedMime = normalizeProjectIconMime(mime); - if (!normalizedMime) { - return null; - } - const ext = PROJECT_ICON_MIME_TO_EXTENSION[normalizedMime]; - return path.join(PROJECT_ICONS_DIR_PATH, `${projectIconBaseName(projectId)}.${ext}`); -}; - -const projectIconPathCandidates = (projectId) => { - const base = projectIconBaseName(projectId); - return Object.values(PROJECT_ICON_MIME_TO_EXTENSION).map((ext) => path.join(PROJECT_ICONS_DIR_PATH, `${base}.${ext}`)); -}; - -const removeProjectIconFiles = async (projectId, keepPath) => { - const candidates = projectIconPathCandidates(projectId); - await Promise.all(candidates.map(async (candidatePath) => { - if (keepPath && candidatePath === keepPath) { - return; - } - try { - await fsPromises.unlink(candidatePath); - } catch (error) { - if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { - throw error; - } - } - })); -}; - -const parseProjectIconDataUrl = (value) => { - if (typeof value !== 'string') { - return { ok: false, error: 'dataUrl is required' }; - } - - const trimmed = value.trim(); - const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i); - if (!match) { - return { ok: false, error: 'Invalid dataUrl format' }; - } - - const mime = normalizeProjectIconMime(match[1]); - if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) { - return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' }; - } - - try { - const base64 = match[2].replace(/\s+/g, ''); - const bytes = Buffer.from(base64, 'base64'); - if (bytes.length === 0) { - return { ok: false, error: 'Icon content is empty' }; - } - if (bytes.length > PROJECT_ICON_MAX_BYTES) { - return { ok: false, error: 'Icon exceeds size limit (5 MB)' }; - } - return { ok: true, mime, bytes }; - } catch { - return { ok: false, error: 'Failed to decode icon data' }; - } -}; - -const normalizeProjectIconThemeVariant = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'light' || normalized === 'dark') { - return normalized; - } - return null; -}; - -const normalizeProjectIconColor = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim(); - if (!PROJECT_ICON_HEX_COLOR_PATTERN.test(normalized)) { - return null; - } - return normalized; -}; - -const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => { - if (typeof svgMarkup !== 'string') { - return svgMarkup; - } - - const color = iconColor || PROJECT_ICON_THEME_COLORS[themeVariant]; - if (!color) { - return svgMarkup; - } - - const svgTagIndex = svgMarkup.search(/', svgTagIndex); - if (svgOpenTagEndIndex === -1) { - return svgMarkup; - } - - const overrideStyle = ``; - return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`; -}; - -const findProjectById = (settings, projectId) => { - const projects = sanitizeProjects(settings?.projects) || []; - const index = projects.findIndex((project) => project.id === projectId); - if (index === -1) { - return { projects, index: -1, project: null }; - } - return { projects, index, project: projects[index] }; -}; - -const readSettingsFromDisk = async () => { - try { - const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed; - } - return {}; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return {}; - } - console.warn('Failed to read settings file:', error); - return {}; - } -}; - -const writeSettingsToDisk = async (settings) => { - try { - await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8'); - } catch (error) { - console.warn('Failed to write settings file:', error); - throw error; - } -}; - -const PUSH_SUBSCRIPTIONS_VERSION = 1; -let persistPushSubscriptionsLock = Promise.resolve(); -let persistManagedRemoteTunnelConfigLock = Promise.resolve(); - -const readPushSubscriptionsFromDisk = async () => { - try { - const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - - const subscriptionsBySession = - parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object' - ? parsed.subscriptionsBySession - : {}; - - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession }; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - console.warn('Failed to read push subscriptions file:', error); - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } -}; - -const writePushSubscriptionsToDisk = async (data) => { - await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); -}; - -const persistPushSubscriptionUpdate = async (mutate) => { - persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => { - await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); - const current = await readPushSubscriptionsFromDisk(); - const next = mutate({ - version: PUSH_SUBSCRIPTIONS_VERSION, - subscriptionsBySession: current.subscriptionsBySession || {}, - }); - await writePushSubscriptionsToDisk(next); - return next; - }); - - return persistPushSubscriptionsLock; -}; - -const sanitizeManagedRemoteTunnelConfigEntries = (value) => { - if (!Array.isArray(value)) { - return []; - } - - const result = []; - const seenIds = new Set(); - const seenHostnames = new Set(); - for (const entry of value) { - if (!entry || typeof entry !== 'object') { - continue; - } - - const id = typeof entry.id === 'string' ? entry.id.trim() : ''; - const name = typeof entry.name === 'string' ? entry.name.trim() : ''; - const hostname = normalizeManagedRemoteTunnelHostname(entry.hostname); - const token = typeof entry.token === 'string' ? entry.token.trim() : ''; - const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(); - - if (!id || !name || !hostname || !token) { - continue; - } - if (seenIds.has(id) || seenHostnames.has(hostname)) { - continue; - } - - seenIds.add(id); - seenHostnames.add(hostname); - result.push({ id, name, hostname, token, updatedAt }); - } - - return result; -}; - -const migrateManagedRemoteTunnelConfigFromLegacyFile = async () => { - try { - const legacyRaw = await fsPromises.readFile(CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(legacyRaw); - const tunnels = sanitizeManagedRemoteTunnelConfigEntries(parsed?.tunnels); - const migrated = { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels, - }; - await writeManagedRemoteTunnelConfigToDisk(migrated); - return migrated; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } - console.warn('Failed to migrate legacy named tunnel config file:', error); - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } -}; - -const readManagedRemoteTunnelConfigFromDisk = async () => { - try { - const raw = await fsPromises.readFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } - - const version = parsed.version === CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION - ? CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION - : CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION; - - return { - version, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(parsed.tunnels), - }; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return migrateManagedRemoteTunnelConfigFromLegacyFile(); - } - console.warn('Failed to read managed remote tunnel config file:', error); - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } -}; - -const writeManagedRemoteTunnelConfigToDisk = async (data) => { - await fsPromises.mkdir(path.dirname(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); -}; - -const updateManagedRemoteTunnelConfig = async (mutate) => { - persistManagedRemoteTunnelConfigLock = persistManagedRemoteTunnelConfigLock.then(async () => { - const current = await readManagedRemoteTunnelConfigFromDisk(); - const next = mutate({ - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(current.tunnels), - }); - - await writeManagedRemoteTunnelConfigToDisk({ - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(next?.tunnels), - }); - }); - - return persistManagedRemoteTunnelConfigLock; -}; - -const syncManagedRemoteTunnelConfigWithPresets = async (presets) => { - const sanitizedPresets = normalizeManagedRemoteTunnelPresets(presets) || []; - - await updateManagedRemoteTunnelConfig((current) => { - const byId = new Map(current.tunnels.map((entry) => [entry.id, entry])); - const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry])); - - const nextTunnels = []; - for (const preset of sanitizedPresets) { - const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null; - if (!existing) { - continue; - } - - nextTunnels.push({ - ...existing, - id: preset.id, - name: preset.name, - hostname: preset.hostname, - }); - } - - return { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: nextTunnels, - }; - }); -}; - -const upsertManagedRemoteTunnelToken = async ({ id, name, hostname, token }) => { - if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') { - return; - } - const normalizedId = id.trim(); - const normalizedName = name.trim(); - const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); - const normalizedToken = token.trim(); - if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) { - return; - } - - await updateManagedRemoteTunnelConfig((current) => { - const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname); - withoutConflicts.push({ - id: normalizedId, - name: normalizedName, - hostname: normalizedHostname, - token: normalizedToken, - updatedAt: Date.now(), - }); - - return { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: withoutConflicts, - }; - }); -}; - -const resolveManagedRemoteTunnelToken = async ({ presetId, hostname }) => { - const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : ''; - const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); - const config = await readManagedRemoteTunnelConfigFromDisk(); - - if (normalizedPresetId) { - const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId); - if (byId?.token) { - return byId.token; - } - } - - if (normalizedHostname) { - const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname); - if (byHostname?.token) { - return byHostname.token; - } - } - - return ''; -}; - -const resolveDirectoryCandidate = (value) => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeDirectoryPath(trimmed); - return path.resolve(normalized); -}; - -const validateDirectoryPath = async (candidate) => { - const resolved = resolveDirectoryCandidate(candidate); - if (!resolved) { - return { ok: false, error: 'Directory parameter is required' }; - } - try { - const stats = await fsPromises.stat(resolved); - if (!stats.isDirectory()) { - return { ok: false, error: 'Specified path is not a directory' }; - } - return { ok: true, directory: resolved }; - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return { ok: false, error: 'Directory not found' }; - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return { ok: false, error: 'Access to directory denied' }; - } - return { ok: false, error: 'Failed to validate directory' }; - } -}; - -const resolveProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; - - if (requested) { - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - return { directory: validated.directory, error: null }; - } - - const settings = await readSettingsFromDiskMigrated(); - const projects = sanitizeProjects(settings.projects) || []; - if (projects.length === 0) { - return { directory: null, error: 'Directory parameter or active project is required' }; - } - - const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; - const active = projects.find((project) => project.id === activeId) || projects[0]; - if (!active || !active.path) { - return { directory: null, error: 'Directory parameter or active project is required' }; - } - - const validated = await validateDirectoryPath(active.path); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - - return { directory: validated.directory, error: null }; -}; - -const isUnsafeSkillRelativePath = (value) => { - if (typeof value !== 'string' || value.length === 0) { - return true; - } - - const normalized = value.replace(/\\/g, '/'); - if (path.posix.isAbsolute(normalized)) { - return true; - } - - return normalized.split('/').some((segment) => segment === '..'); -}; - -const resolveOptionalProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; - - if (!requested) { - return { directory: null, error: null }; - } - - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - - return { directory: validated.directory, error: null }; -}; - -const sanitizeTypographySizesPartial = (input) => { - if (!input || typeof input !== 'object') { - return undefined; - } - const candidate = input; - const result = {}; - let populated = false; - - const assign = (key) => { - if (typeof candidate[key] === 'string' && candidate[key].length > 0) { - result[key] = candidate[key]; - populated = true; - } - }; - - assign('markdown'); - assign('code'); - assign('uiHeader'); - assign('uiLabel'); - assign('meta'); - assign('micro'); - - return populated ? result : undefined; -}; - -const normalizeStringArray = (input) => { - if (!Array.isArray(input)) { - return []; - } - return Array.from( - new Set( - input.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ); -}; - -const sanitizeModelRefs = (input, limit) => { - if (!Array.isArray(input)) { - return undefined; - } - - const result = []; - const seen = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : ''; - const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : ''; - if (!providerID || !modelID) continue; - const key = `${providerID}/${modelID}`; - if (seen.has(key)) continue; - seen.add(key); - result.push({ providerID, modelID }); - if (result.length >= limit) break; - } - - return result; -}; - -const sanitizeSkillCatalogs = (input) => { - if (!Array.isArray(input)) { - return undefined; - } - - const result = []; - const seen = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - - const id = typeof entry.id === 'string' ? entry.id.trim() : ''; - const label = typeof entry.label === 'string' ? entry.label.trim() : ''; - const source = typeof entry.source === 'string' ? entry.source.trim() : ''; - const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : ''; - const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : ''; - - if (!id || !label || !source) continue; - if (seen.has(id)) continue; - seen.add(id); - - result.push({ - id, - label, - source, - ...(subpath ? { subpath } : {}), - ...(gitIdentityId ? { gitIdentityId } : {}), - }); - } - - return result; -}; - -const sanitizeProjects = (input) => { - if (!Array.isArray(input)) { - return undefined; - } - - const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; - const normalizeIconBackground = (value) => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null; - }; - - const result = []; - const seenIds = new Set(); - const seenPaths = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - - const candidate = entry; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; - const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : ''; - const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; - const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : ''; - const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object' - ? candidate.iconImage - : null; - const iconBackground = normalizeIconBackground(candidate.iconBackground); - const color = typeof candidate.color === 'string' ? candidate.color.trim() : ''; - const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; - const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) - ? Number(candidate.lastOpenedAt) - : null; - - if (!id || !normalizedPath) continue; - if (seenIds.has(id)) continue; - if (seenPaths.has(normalizedPath)) continue; - - seenIds.add(id); - seenPaths.add(normalizedPath); - - const project = { - id, - path: normalizedPath, - ...(label ? { label } : {}), - ...(icon ? { icon } : {}), - ...(iconBackground ? { iconBackground } : {}), - ...(color ? { color } : {}), - ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), - ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), - }; - - if (candidate.iconImage === null) { - project.iconImage = null; - } else if (iconImage) { - const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : ''; - const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt) - ? Math.max(0, Math.round(iconImage.updatedAt)) - : 0; - const source = iconImage.source === 'custom' || iconImage.source === 'auto' - ? iconImage.source - : null; - if (mime && updatedAt > 0 && source) { - project.iconImage = { mime, updatedAt, source }; - } - } - - if (candidate.iconBackground === null) { - project.iconBackground = null; - } - - if (typeof candidate.sidebarCollapsed === 'boolean') { - project.sidebarCollapsed = candidate.sidebarCollapsed; - } - - result.push(project); - } - - return result; -}; - -const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant'; -const PWA_APP_NAME_MAX_LENGTH = 64; - -const normalizePwaAppName = (value, fallback = '') => { - if (typeof value !== 'string') { - return fallback; - } - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return fallback; - } - return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH); -}; - -const sanitizeSettingsUpdate = (payload) => { - if (!payload || typeof payload !== 'object') { - return {}; - } - - const candidate = payload; - const result = {}; - - if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) { - result.themeId = candidate.themeId; - } - if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { - result.themeVariant = candidate.themeVariant; - } - if (typeof candidate.useSystemTheme === 'boolean') { - result.useSystemTheme = candidate.useSystemTheme; - } - if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) { - result.lightThemeId = candidate.lightThemeId; - } - if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { - result.darkThemeId = candidate.darkThemeId; - } - if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) { - result.splashBgLight = candidate.splashBgLight.trim(); - } - if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) { - result.splashFgLight = candidate.splashFgLight.trim(); - } - if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) { - result.splashBgDark = candidate.splashBgDark.trim(); - } - if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) { - result.splashFgDark = candidate.splashFgDark.trim(); - } - if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { - const normalized = normalizePathForPersistence(candidate.lastDirectory); - if (typeof normalized === 'string' && normalized.length > 0) { - result.lastDirectory = normalized; - } - } - if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { - const normalized = normalizePathForPersistence(candidate.homeDirectory); - if (typeof normalized === 'string' && normalized.length > 0) { - result.homeDirectory = normalized; - } - } - - // Absolute path to the opencode CLI binary (optional override). - // Accept empty-string to clear (we persist an empty string sentinel so the running - // process can reliably drop a previously applied OPENCODE_BINARY override). - if (typeof candidate.opencodeBinary === 'string') { - const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); - result.opencodeBinary = normalized; - } - if (Array.isArray(candidate.projects)) { - const projects = sanitizeProjects(candidate.projects); - if (projects) { - result.projects = projects; - } - } - if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { - result.activeProjectId = candidate.activeProjectId; - } - - if (Array.isArray(candidate.approvedDirectories)) { - result.approvedDirectories = normalizeStringArray( - candidate.approvedDirectories - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - } - if (Array.isArray(candidate.securityScopedBookmarks)) { - result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks); - } - if (Array.isArray(candidate.pinnedDirectories)) { - result.pinnedDirectories = normalizeStringArray( - candidate.pinnedDirectories - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - } - - - if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) { - result.uiFont = candidate.uiFont; - } - if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) { - result.monoFont = candidate.monoFont; - } - if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) { - result.markdownDisplayMode = candidate.markdownDisplayMode; - } - if (typeof candidate.githubClientId === 'string') { - const trimmed = candidate.githubClientId.trim(); - if (trimmed.length > 0) { - result.githubClientId = trimmed; - } - } - if (typeof candidate.githubScopes === 'string') { - const trimmed = candidate.githubScopes.trim(); - if (trimmed.length > 0) { - result.githubScopes = trimmed; - } - } - if (typeof candidate.showReasoningTraces === 'boolean') { - result.showReasoningTraces = candidate.showReasoningTraces; - } - if (typeof candidate.showTextJustificationActivity === 'boolean') { - result.showTextJustificationActivity = candidate.showTextJustificationActivity; - } - if (typeof candidate.showDeletionDialog === 'boolean') { - result.showDeletionDialog = candidate.showDeletionDialog; - } - if (typeof candidate.nativeNotificationsEnabled === 'boolean') { - result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; - } - if (typeof candidate.notificationMode === 'string') { - const mode = candidate.notificationMode.trim(); - if (mode === 'always' || mode === 'hidden-only') { - result.notificationMode = mode; - } - } - if (typeof candidate.notifyOnSubtasks === 'boolean') { - result.notifyOnSubtasks = candidate.notifyOnSubtasks; - } - if (typeof candidate.notifyOnCompletion === 'boolean') { - result.notifyOnCompletion = candidate.notifyOnCompletion; - } - if (typeof candidate.notifyOnError === 'boolean') { - result.notifyOnError = candidate.notifyOnError; - } - if (typeof candidate.notifyOnQuestion === 'boolean') { - result.notifyOnQuestion = candidate.notifyOnQuestion; - } - if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') { - result.notificationTemplates = candidate.notificationTemplates; - } - if (typeof candidate.summarizeLastMessage === 'boolean') { - result.summarizeLastMessage = candidate.summarizeLastMessage; - } - if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) { - result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold)); - } - if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) { - result.summaryLength = Math.max(10, Math.round(candidate.summaryLength)); - } - if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) { - result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength)); - } - if (typeof candidate.usageAutoRefresh === 'boolean') { - result.usageAutoRefresh = candidate.usageAutoRefresh; - } - if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { - result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs))); - } - if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { - result.usageDisplayMode = candidate.usageDisplayMode; - } - if (Array.isArray(candidate.usageDropdownProviders)) { - result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders); - } - if (typeof candidate.autoDeleteEnabled === 'boolean') { - result.autoDeleteEnabled = candidate.autoDeleteEnabled; - } - if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { - const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); - result.autoDeleteAfterDays = normalizedDays; - } - if (candidate.tunnelBootstrapTtlMs === null) { - result.tunnelBootstrapTtlMs = null; - } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) { - result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs); - } - if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) { - result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs); - } - if (typeof candidate.tunnelProvider === 'string') { - const provider = normalizeTunnelProvider(candidate.tunnelProvider); - if (provider) { - result.tunnelProvider = provider; - } - } - if (typeof candidate.tunnelMode === 'string') { - result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode); - } - if (candidate.managedLocalTunnelConfigPath === null) { - result.managedLocalTunnelConfigPath = null; - } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') { - const trimmed = candidate.managedLocalTunnelConfigPath.trim(); - result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null; - } - if (typeof candidate.managedRemoteTunnelHostname === 'string') { - const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname); - result.managedRemoteTunnelHostname = hostname; - } - if (candidate.managedRemoteTunnelToken === null) { - result.managedRemoteTunnelToken = null; - } else if (typeof candidate.managedRemoteTunnelToken === 'string') { - result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim(); - } - const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets); - if (managedRemoteTunnelPresets) { - result.managedRemoteTunnelPresets = managedRemoteTunnelPresets; - } - const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens); - if (managedRemoteTunnelPresetTokens) { - result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens; - } - if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') { - const id = candidate.managedRemoteTunnelSelectedPresetId.trim(); - result.managedRemoteTunnelSelectedPresetId = id || undefined; - } - - const typography = sanitizeTypographySizesPartial(candidate.typographySizes); - if (typography) { - result.typographySizes = typography; - } - - if (typeof candidate.defaultModel === 'string') { - const trimmed = candidate.defaultModel.trim(); - result.defaultModel = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultVariant === 'string') { - const trimmed = candidate.defaultVariant.trim(); - result.defaultVariant = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultAgent === 'string') { - const trimmed = candidate.defaultAgent.trim(); - result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultGitIdentityId === 'string') { - const trimmed = candidate.defaultGitIdentityId.trim(); - result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; - } - if (typeof candidate.autoCreateWorktree === 'boolean') { - result.autoCreateWorktree = candidate.autoCreateWorktree; - } - if (typeof candidate.gitmojiEnabled === 'boolean') { - result.gitmojiEnabled = candidate.gitmojiEnabled; - } - if (typeof candidate.zenModel === 'string') { - const trimmed = candidate.zenModel.trim(); - result.zenModel = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.gitProviderId === 'string') { - const trimmed = candidate.gitProviderId.trim(); - result.gitProviderId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.gitModelId === 'string') { - const trimmed = candidate.gitModelId.trim(); - result.gitModelId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.pwaAppName === 'string') { - result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); - } - if (typeof candidate.toolCallExpansion === 'string') { - const mode = candidate.toolCallExpansion.trim(); - if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { - result.toolCallExpansion = mode; - } - } - if (typeof candidate.inputSpellcheckEnabled === 'boolean') { - result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; - } - if (typeof candidate.showToolFileIcons === 'boolean') { - result.showToolFileIcons = candidate.showToolFileIcons; - } - if (typeof candidate.showExpandedBashTools === 'boolean') { - result.showExpandedBashTools = candidate.showExpandedBashTools; - } - if (typeof candidate.showExpandedEditTools === 'boolean') { - result.showExpandedEditTools = candidate.showExpandedEditTools; - } - if (typeof candidate.chatRenderMode === 'string') { - const mode = candidate.chatRenderMode.trim(); - if (mode === 'sorted' || mode === 'live') { - result.chatRenderMode = mode; - } - } - if (typeof candidate.activityRenderMode === 'string') { - const mode = candidate.activityRenderMode.trim(); - if (mode === 'collapsed' || mode === 'summary') { - result.activityRenderMode = mode; - } - } - if (typeof candidate.mermaidRenderingMode === 'string') { - const mode = candidate.mermaidRenderingMode.trim(); - if (mode === 'svg' || mode === 'ascii') { - result.mermaidRenderingMode = mode; - } - } - if (typeof candidate.userMessageRenderingMode === 'string') { - const mode = candidate.userMessageRenderingMode.trim(); - if (mode === 'markdown' || mode === 'plain') { - result.userMessageRenderingMode = mode; - } - } - if (typeof candidate.stickyUserHeader === 'boolean') { - result.stickyUserHeader = candidate.stickyUserHeader; - } - if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { - result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); - } - if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) { - result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize))); - } - if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) { - result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding))); - } - if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) { - result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius))); - } - if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { - result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset))); - } - - const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); - if (favoriteModels) { - result.favoriteModels = favoriteModels; - } - - const recentModels = sanitizeModelRefs(candidate.recentModels, 16); - if (recentModels) { - result.recentModels = recentModels; - } - if (typeof candidate.diffLayoutPreference === 'string') { - const mode = candidate.diffLayoutPreference.trim(); - if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { - result.diffLayoutPreference = mode; - } - } - if (typeof candidate.diffViewMode === 'string') { - const mode = candidate.diffViewMode.trim(); - if (mode === 'single' || mode === 'stacked') { - result.diffViewMode = mode; - } - } - if (typeof candidate.directoryShowHidden === 'boolean') { - result.directoryShowHidden = candidate.directoryShowHidden; - } - if (typeof candidate.filesViewShowGitignored === 'boolean') { - result.filesViewShowGitignored = candidate.filesViewShowGitignored; - } - if (typeof candidate.openInAppId === 'string') { - const trimmed = candidate.openInAppId.trim(); - if (trimmed.length > 0) { - result.openInAppId = trimmed; - } - } - - // Message limit — single setting for fetch / trim / Load More chunk - if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { - result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit))); - } - - const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); - if (skillCatalogs) { - result.skillCatalogs = skillCatalogs; - } - - // Usage model selections - which models appear in dropdown - if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') { - const sanitized = {}; - for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) { - if (typeof providerId === 'string' && Array.isArray(models)) { - const validModels = models.filter((m) => typeof m === 'string' && m.length > 0); - if (validModels.length > 0) { - sanitized[providerId] = validModels; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageSelectedModels = sanitized; - } - } - - // Usage page collapsed families - for "Other Models" section - if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') { - const sanitized = {}; - for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) { - if (typeof providerId === 'string' && Array.isArray(families)) { - const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); - if (validFamilies.length > 0) { - sanitized[providerId] = validFamilies; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageCollapsedFamilies = sanitized; - } - } - - // Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed) - if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') { - const sanitized = {}; - for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) { - if (typeof providerId === 'string' && Array.isArray(families)) { - const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); - if (validFamilies.length > 0) { - sanitized[providerId] = validFamilies; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageExpandedFamilies = sanitized; - } - } - - // Custom model groups configuration - if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') { - const sanitized = {}; - for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) { - if (typeof providerId !== 'string') continue; - - const providerConfig = {}; - - // customGroups: array of {id, label, models, order} - if (Array.isArray(config.customGroups)) { - const validGroups = config.customGroups - .filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string') - .map((g) => ({ - id: g.id.slice(0, 64), - label: g.label.slice(0, 128), - models: Array.isArray(g.models) - ? g.models.filter((m) => typeof m === 'string').slice(0, 500) - : [], - order: typeof g.order === 'number' ? g.order : 0, - })); - if (validGroups.length > 0) { - providerConfig.customGroups = validGroups; - } - } - - // modelAssignments: Record - if (config.modelAssignments && typeof config.modelAssignments === 'object') { - const assignments = {}; - for (const [model, groupId] of Object.entries(config.modelAssignments)) { - if (typeof model === 'string' && typeof groupId === 'string') { - assignments[model] = groupId; - } - } - if (Object.keys(assignments).length > 0) { - providerConfig.modelAssignments = assignments; - } - } - - // renamedGroups: Record - if (config.renamedGroups && typeof config.renamedGroups === 'object') { - const renamed = {}; - for (const [groupId, label] of Object.entries(config.renamedGroups)) { - if (typeof groupId === 'string' && typeof label === 'string') { - renamed[groupId] = label.slice(0, 128); - } - } - if (Object.keys(renamed).length > 0) { - providerConfig.renamedGroups = renamed; - } - } - - if (Object.keys(providerConfig).length > 0) { - sanitized[providerId] = providerConfig; - } - } - if (Object.keys(sanitized).length > 0) { - result.usageModelGroups = sanitized; - } - } - - // Usage reporting opt-out (default: true/enabled) - if (typeof candidate.reportUsage === 'boolean') { - result.reportUsage = candidate.reportUsage; - } - - return result; -}; - -const mergePersistedSettings = (current, changes) => { - const baseApproved = Array.isArray(changes.approvedDirectories) - ? changes.approvedDirectories - : Array.isArray(current.approvedDirectories) - ? current.approvedDirectories - : []; - - const additionalApproved = []; - if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) { - additionalApproved.push(changes.lastDirectory); - } - if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { - additionalApproved.push(changes.homeDirectory); - } - const projectEntries = Array.isArray(changes.projects) - ? changes.projects - : Array.isArray(current.projects) - ? current.projects - : []; - projectEntries.forEach((project) => { - if (project && typeof project.path === 'string' && project.path.length > 0) { - additionalApproved.push(project.path); - } - }); - const approvedSource = [...baseApproved, ...additionalApproved]; - - const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) - ? changes.securityScopedBookmarks - : Array.isArray(current.securityScopedBookmarks) - ? current.securityScopedBookmarks - : []; - - const nextTypographySizes = changes.typographySizes - ? { - ...(current.typographySizes || {}), - ...changes.typographySizes - } - : current.typographySizes; - - const next = { - ...current, - ...changes, - approvedDirectories: Array.from( - new Set( - approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ), - securityScopedBookmarks: Array.from( - new Set( - baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ), - typographySizes: nextTypographySizes - }; - - return next; -}; - -const formatSettingsResponse = (settings) => { - const sanitized = sanitizeSettingsUpdate(settings); - delete sanitized.managedRemoteTunnelToken; - const approved = normalizeStringArray(settings.approvedDirectories); - const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); - const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; - const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); - - return { - ...sanitized, - hasManagedRemoteTunnelToken, - ...(pwaAppName ? { pwaAppName } : {}), - approvedDirectories: approved, - securityScopedBookmarks: bookmarks, - pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), - typographySizes: sanitizeTypographySizesPartial(settings.typographySizes), - showReasoningTraces: - typeof settings.showReasoningTraces === 'boolean' - ? settings.showReasoningTraces - : typeof sanitized.showReasoningTraces === 'boolean' - ? sanitized.showReasoningTraces - : false - }; -}; - -const validateProjectEntries = async (projects) => { - console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`); - - if (!Array.isArray(projects)) { - console.warn(`[validateProjectEntries] Input is not an array, returning empty`); - return []; - } - - const validations = projects.map(async (project) => { - if (!project || typeof project.path !== 'string' || project.path.length === 0) { - console.error(`[validateProjectEntries] Invalid project entry: missing or empty path`, project); - return null; - } - try { - const stats = await fsPromises.stat(project.path); - if (!stats.isDirectory()) { - console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`); - return null; - } - return project; - } catch (error) { - const err = error; - console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`); - if (err && typeof err === 'object' && err.code === 'ENOENT') { - console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`); - return null; - } - console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`); - return project; - } - }); - - const results = (await Promise.all(validations)).filter((p) => p !== null); - - console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`); - return results; -}; - -const migrateSettingsFromLegacyLastDirectory = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const now = Date.now(); - - const sanitizedProjects = sanitizeProjects(settings.projects) || []; - let nextProjects = sanitizedProjects; - let nextActiveProjectId = - typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined; - - let changed = false; - - if (nextProjects.length === 0) { - const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : ''; - const candidate = legacy ? resolveDirectoryCandidate(legacy) : null; - - if (candidate) { - try { - const stats = await fsPromises.stat(candidate); - if (stats.isDirectory()) { - const id = crypto.randomUUID(); - nextProjects = [ - { - id, - path: candidate, - addedAt: now, - lastOpenedAt: now, - }, - ]; - nextActiveProjectId = id; - changed = true; - } - } catch { - // ignore invalid lastDirectory - } - } - } - - if (nextProjects.length > 0) { - const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null; - if (!active) { - nextActiveProjectId = nextProjects[0].id; - changed = true; - } - } else if (nextActiveProjectId) { - nextActiveProjectId = undefined; - changed = true; - } - - if (!changed) { - return { settings, changed: false }; - } - - const merged = mergePersistedSettings(settings, { - ...settings, - projects: nextProjects, - ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }), - }); - - return { settings: merged, changed: true }; -}; - -const migrateSettingsFromLegacyThemePreferences = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - - const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : ''; - const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : ''; - - const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0; - const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0; - - if (hasLight && hasDark) { - return { settings, changed: false }; - } - - const defaultLight = 'flexoki-light'; - const defaultDark = 'flexoki-dark'; - - let nextLightThemeId = hasLight ? settings.lightThemeId : undefined; - let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined; - - if (!hasLight) { - if (themeId && themeVariant === 'light') { - nextLightThemeId = themeId; - } else { - nextLightThemeId = defaultLight; - } - } - - if (!hasDark) { - if (themeId && themeVariant === 'dark') { - nextDarkThemeId = themeId; - } else { - nextDarkThemeId = defaultDark; - } - } - - const merged = mergePersistedSettings(settings, { - ...settings, - ...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}), - ...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}), - }); - - return { settings: merged, changed: true }; -}; - -const migrateSettingsFromLegacyCollapsedProjects = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const collapsed = Array.isArray(settings.collapsedProjects) - ? normalizeStringArray(settings.collapsedProjects) - : []; - - if (collapsed.length === 0 || !Array.isArray(settings.projects)) { - if (collapsed.length === 0) { - return { settings, changed: false }; - } - // Nothing to apply to; drop legacy key. - const next = { ...settings }; - delete next.collapsedProjects; - return { settings: next, changed: true }; - } - - const set = new Set(collapsed); - const projects = sanitizeProjects(settings.projects) || []; - let changed = false; - - const nextProjects = projects.map((project) => { - const shouldCollapse = set.has(project.id); - if (project.sidebarCollapsed !== shouldCollapse) { - changed = true; - return { ...project, sidebarCollapsed: shouldCollapse }; - } - return project; - }); - - if (!changed) { - // Still drop legacy key if present. - if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) { - const next = { ...settings }; - delete next.collapsedProjects; - return { settings: next, changed: true }; - } - return { settings, changed: false }; - } - - const next = { ...settings, projects: nextProjects }; - delete next.collapsedProjects; - return { settings: next, changed: true }; -}; - -const DEFAULT_NOTIFICATION_TEMPLATES = { - completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, - error: { title: 'Tool error', message: '{last_message}' }, - question: { title: 'Input needed', message: '{last_message}' }, - subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, -}; - -const ensureNotificationTemplateShape = (templates) => { - const input = templates && typeof templates === 'object' ? templates : {}; - let changed = false; - const next = {}; - - for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) { - const currentEntry = input[event]; - const base = DEFAULT_NOTIFICATION_TEMPLATES[event]; - const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title; - const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message; - if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') { - changed = true; - } - next[event] = { title: currentTitle, message: currentMessage }; - } - - return { templates: next, changed }; -}; - -const migrateSettingsNotificationDefaults = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - let changed = false; - const next = { ...settings }; - - if (typeof settings.notifyOnSubtasks !== 'boolean') { - next.notifyOnSubtasks = true; - changed = true; - } - if (typeof settings.notifyOnCompletion !== 'boolean') { - next.notifyOnCompletion = true; - changed = true; - } - if (typeof settings.notifyOnError !== 'boolean') { - next.notifyOnError = true; - changed = true; - } - if (typeof settings.notifyOnQuestion !== 'boolean') { - next.notifyOnQuestion = true; - changed = true; - } - - const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates); - if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') { - next.notificationTemplates = templates; - changed = true; - } - - return { settings: changed ? next : settings, changed }; -}; - -const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const next = { ...settings }; - let changed = false; - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) { - next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) { - if (next.namedTunnelToken === null) { - next.managedRemoteTunnelToken = null; - } else if (typeof next.namedTunnelToken === 'string') { - next.managedRemoteTunnelToken = next.namedTunnelToken.trim(); - } - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) { - next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) { - next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) { - const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string' - ? next.namedTunnelSelectedPresetId.trim() - : ''; - if (selectedPresetId) { - next.managedRemoteTunnelSelectedPresetId = selectedPresetId; - } - changed = true; - } - - const legacyKeys = [ - 'namedTunnelHostname', - 'namedTunnelToken', - 'namedTunnelPresets', - 'namedTunnelPresetTokens', - 'namedTunnelSelectedPresetId', - ]; - for (const key of legacyKeys) { - if (Object.prototype.hasOwnProperty.call(next, key)) { - delete next[key]; - changed = true; - } - } - - return { settings: changed ? next : settings, changed }; -}; - -const readSettingsFromDiskMigrated = async () => { - const current = await readSettingsFromDisk(); - const migration1 = await migrateSettingsFromLegacyLastDirectory(current); - const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings); - const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings); - const migration4 = await migrateSettingsNotificationDefaults(migration3.settings); - const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings); - const migration6 = normalizeSettingsPaths(migration5.settings); - if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) { - await writeSettingsToDisk(migration6.settings); - } - return migration6.settings; -}; - -const getOrCreateVapidKeys = async () => { - const settings = await readSettingsFromDiskMigrated(); - const existing = settings?.vapidKeys; - if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') { - return { publicKey: existing.publicKey, privateKey: existing.privateKey }; - } - - const generated = webPush.generateVAPIDKeys(); - const next = { - ...settings, - vapidKeys: { - publicKey: generated.publicKey, - privateKey: generated.privateKey, - }, - }; - - await writeSettingsToDisk(next); - return { publicKey: generated.publicKey, privateKey: generated.privateKey }; -}; - -const getUiSessionTokenFromRequest = (req) => { - const cookieHeader = req?.headers?.cookie; - if (!cookieHeader || typeof cookieHeader !== 'string') { - return null; - } - const segments = cookieHeader.split(';'); - for (const segment of segments) { - const [rawName, ...rest] = segment.split('='); - const name = rawName?.trim(); - if (!name) continue; - if (name !== 'oc_ui_session') continue; - const value = rest.join('=').trim(); - try { - return decodeURIComponent(value || ''); - } catch { - return value || null; - } - } - return null; -}; +const managedTunnelConfigRuntime = createManagedTunnelConfigRuntime({ + fsPromises, + path, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + constants: { + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, + CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + }, +}); + +const readManagedRemoteTunnelConfigFromDisk = (...args) => managedTunnelConfigRuntime.readManagedRemoteTunnelConfigFromDisk(...args); +const syncManagedRemoteTunnelConfigWithPresets = (...args) => managedTunnelConfigRuntime.syncManagedRemoteTunnelConfigWithPresets(...args); +const upsertManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.upsertManagedRemoteTunnelToken(...args); +const resolveManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.resolveManagedRemoteTunnelToken(...args); + +const settingsHelpers = createSettingsHelpers({ + normalizePathForPersistence, + normalizeDirectoryPath, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, +}); + +const normalizePwaAppName = (...args) => settingsHelpers.normalizePwaAppName(...args); +const sanitizeSettingsUpdate = (...args) => settingsHelpers.sanitizeSettingsUpdate(...args); +const mergePersistedSettings = (...args) => settingsHelpers.mergePersistedSettings(...args); +const formatSettingsResponse = (...args) => settingsHelpers.formatSettingsResponse(...args); + +const projectDirectoryRuntime = createProjectDirectoryRuntime({ + fsPromises, + path, + normalizeDirectoryPath, + getReadSettingsFromDiskMigrated: () => readSettingsFromDiskMigrated, + sanitizeProjects, +}); + +const resolveDirectoryCandidate = (...args) => projectDirectoryRuntime.resolveDirectoryCandidate(...args); +const validateDirectoryPath = (...args) => projectDirectoryRuntime.validateDirectoryPath(...args); +const resolveProjectDirectory = (...args) => projectDirectoryRuntime.resolveProjectDirectory(...args); +const resolveOptionalProjectDirectory = (...args) => projectDirectoryRuntime.resolveOptionalProjectDirectory(...args); + +const settingsRuntime = createSettingsRuntime({ + fsPromises, + path, + crypto, + SETTINGS_FILE_PATH, + sanitizeProjects, + sanitizeSettingsUpdate, + mergePersistedSettings, + normalizeSettingsPaths, + normalizeStringArray, + formatSettingsResponse, + resolveDirectoryCandidate, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, +}); + +const readSettingsFromDiskMigrated = (...args) => settingsRuntime.readSettingsFromDiskMigrated(...args); +const readSettingsFromDisk = (...args) => settingsRuntime.readSettingsFromDisk(...args); +const writeSettingsToDisk = (...args) => settingsRuntime.writeSettingsToDisk(...args); +const persistSettings = (...args) => settingsRuntime.persistSettings(...args); + +const requestSecurityRuntime = createRequestSecurityRuntime({ + readSettingsFromDiskMigrated, +}); + +const getUiSessionTokenFromRequest = (...args) => requestSecurityRuntime.getUiSessionTokenFromRequest(...args); + +const pushRuntime = createPushRuntime({ + fsPromises, + path, + webPush, + PUSH_SUBSCRIPTIONS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, +}); + +const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...args); +const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args); +const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args); +const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args); +const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args); +const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args); +const isUiVisible = (...args) => pushRuntime.isUiVisible(...args); +const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args); +const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args); const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128; const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000; const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000; -const rejectWebSocketUpgrade = (socket, statusCode, reason) => { - if (!socket || socket.destroyed) { - return; - } +const rejectWebSocketUpgrade = (...args) => requestSecurityRuntime.rejectWebSocketUpgrade(...args); - const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request'; - const body = Buffer.from(message, 'utf8'); - const statusText = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - }[statusCode] || 'Bad Request'; - try { - socket.write( - `HTTP/1.1 ${statusCode} ${statusText}\r\n` + - 'Connection: close\r\n' + - 'Content-Type: text/plain; charset=utf-8\r\n' + - `Content-Length: ${body.length}\r\n\r\n` - ); - socket.write(body); - } catch { - } +const isRequestOriginAllowed = (...args) => requestSecurityRuntime.isRequestOriginAllowed(...args); - try { - socket.destroy(); - } catch { - } -}; +const notificationEmitterRuntime = createNotificationEmitterRuntime({ + process, + getDesktopNotifyEnabled: () => ENV_DESKTOP_NOTIFY, + desktopNotifyPrefix: DESKTOP_NOTIFY_PREFIX, + getUiNotificationClients: () => uiNotificationClients, +}); +const writeSseEvent = (...args) => notificationEmitterRuntime.writeSseEvent(...args); +const emitDesktopNotification = (...args) => notificationEmitterRuntime.emitDesktopNotification(...args); +const broadcastUiNotification = (...args) => notificationEmitterRuntime.broadcastUiNotification(...args); -const getRequestOriginCandidates = async (req) => { - const origins = new Set(); - const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' - ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() - : ''; - const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); - - const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string' - ? req.headers['x-forwarded-host'].split(',')[0].trim() - : ''; - const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); - - if (host) { - origins.add(`${protocol}://${host}`); - const [hostname, port] = host.split(':'); - const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; - const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : ''; - if (normalizedHost === 'localhost') { - origins.add(`${protocol}://127.0.0.1${portSuffix}`); - origins.add(`${protocol}://[::1]${portSuffix}`); - } else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') { - origins.add(`${protocol}://localhost${portSuffix}`); - } - } - - try { - const settings = await readSettingsFromDiskMigrated(); - if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) { - origins.add(new URL(settings.publicOrigin.trim()).origin); - } - } catch { - } - - return origins; -}; - -const isRequestOriginAllowed = async (req) => { - const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; - if (!originHeader) { - return false; - } - - let normalizedOrigin = ''; - try { - normalizedOrigin = new URL(originHeader).origin; - } catch { - return false; - } - - const allowedOrigins = await getRequestOriginCandidates(req); - return allowedOrigins.has(normalizedOrigin); -}; - -const normalizePushSubscriptions = (record) => { - if (!Array.isArray(record)) return []; - return record - .map((entry) => { - if (!entry || typeof entry !== 'object') return null; - const endpoint = entry.endpoint; - const p256dh = entry.p256dh; - const auth = entry.auth; - if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') { - return null; - } - return { - endpoint, - p256dh, - auth, - createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, - }; - }) - .filter(Boolean); -}; - -const getPushSubscriptionsForUiSession = async (uiSessionToken) => { - if (!uiSessionToken) return []; - const store = await readPushSubscriptionsFromDisk(); - const record = store.subscriptionsBySession?.[uiSessionToken]; - return normalizePushSubscriptions(record); -}; - -const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { - if (!uiSessionToken) { - return; - } - - await ensurePushInitialized(); - - const now = Date.now(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; - - const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); - - filtered.unshift({ - endpoint: subscription.endpoint, - p256dh: subscription.p256dh, - auth: subscription.auth, - createdAt: now, - lastSeenAt: now, - userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, - }); - - subsBySession[uiSessionToken] = filtered.slice(0, 10); - - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const removePushSubscription = async (uiSessionToken, endpoint) => { - if (!uiSessionToken || !endpoint) return; - - await ensurePushInitialized(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; - const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); - if (filtered.length === 0) { - delete subsBySession[uiSessionToken]; - } else { - subsBySession[uiSessionToken] = filtered; - } - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const removePushSubscriptionFromAllSessions = async (endpoint) => { - if (!endpoint) return; - - await ensurePushInitialized(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - for (const [token, entries] of Object.entries(subsBySession)) { - if (!Array.isArray(entries)) continue; - const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); - if (filtered.length === 0) { - delete subsBySession[token]; - } else { - subsBySession[token] = filtered; - } - } - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const buildSessionDeepLinkUrl = (sessionId) => { - if (!sessionId || typeof sessionId !== 'string') { - return '/'; - } - return `/?session=${encodeURIComponent(sessionId)}`; -}; - -const sendPushToSubscription = async (sub, payload) => { - await ensurePushInitialized(); - const body = JSON.stringify(payload); - - const pushSubscription = { - endpoint: sub.endpoint, - keys: { - p256dh: sub.p256dh, - auth: sub.auth, - } - }; - - try { - await webPush.sendNotification(pushSubscription, body); - } catch (error) { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; - if (statusCode === 410 || statusCode === 404) { - await removePushSubscriptionFromAllSessions(sub.endpoint); - return; - } - console.warn('[Push] Failed to send notification:', error); - } -}; - -const sendPushToAllUiSessions = async (payload, options = {}) => { - const requireNoSse = options.requireNoSse === true; - const store = await readPushSubscriptionsFromDisk(); - const sessions = store.subscriptionsBySession || {}; - const subscriptionsByEndpoint = new Map(); - - for (const [token, record] of Object.entries(sessions)) { - const subscriptions = normalizePushSubscriptions(record); - if (subscriptions.length === 0) continue; - - for (const sub of subscriptions) { - if (!subscriptionsByEndpoint.has(sub.endpoint)) { - subscriptionsByEndpoint.set(sub.endpoint, sub); - } - } - } - - await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => { - if (requireNoSse && isAnyUiVisible()) { - return; - } - await sendPushToSubscription(sub, payload); - })); -}; - -let pushInitialized = false; - - - -const uiVisibilityByToken = new Map(); -let globalVisibilityState = false; - -const updateUiVisibility = (token, visible) => { - if (!token) return; - const now = Date.now(); - const nextVisible = Boolean(visible); - uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); - globalVisibilityState = nextVisible; - -}; - -const isAnyUiVisible = () => globalVisibilityState === true; - -const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; - -// Session activity tracking (mirrors desktop session_activity.rs) -const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'|'cooldown', updatedAt: number } -const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId -const SESSION_COOLDOWN_DURATION_MS = 2000; - -// Complete session status tracking - source of truth for web clients -// This maintains the authoritative state, clients only cache it -const sessionStates = new Map(); // sessionId -> { -// status: 'idle'|'busy'|'retry', -// lastUpdateAt: number, -// lastEventId: string, -// metadata: { attempt?: number, message?: string, next?: number } -// } -const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours -const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour - -const updateSessionState = (sessionId, status, eventId, metadata = {}) => { - if (!sessionId || typeof sessionId !== 'string') return; - - const now = Date.now(); - const existing = sessionStates.get(sessionId); - const existingAttentionState = sessionAttentionStates.get(sessionId); - - // Only update if this is a newer event (simple ordering protection) - if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) { - // Same status within 5 seconds, skip to reduce noise - return; - } - - sessionStates.set(sessionId, { - status, - lastUpdateAt: now, - lastEventId: eventId || `server-${now}`, - metadata: { ...existing?.metadata, ...metadata } - }); - - // Update attention tracking state (must be called before broadcasting) - updateSessionAttentionStatus(sessionId, status, eventId); - const attentionState = sessionAttentionStates.get(sessionId); - - // Broadcast status change to connected web clients via SSE - // This enables real-time updates without polling - // Include needsAttention in the same event to ensure atomic updates - const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention; - if (uiNotificationClients.size > 0 && (!existing || existing.status !== status || attentionChanged)) { - const state = sessionStates.get(sessionId); - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:session-status', - properties: { - sessionId, - status: state.status, - timestamp: state.lastUpdateAt, - metadata: state.metadata, - needsAttention: attentionState?.needsAttention ?? false - } - }); - } catch { - // Client disconnected, will be cleaned up by close handler - } - } - } - - // Also update activity phases for backward compatibility - const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; - setSessionActivityPhase(sessionId, phase); -}; - -const getSessionStateSnapshot = () => { - const result = {}; - const now = Date.now(); - - for (const [sessionId, data] of sessionStates) { - // Skip very old states (session likely gone) - if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; - - result[sessionId] = { - status: data.status, - lastUpdateAt: data.lastUpdateAt, - metadata: data.metadata - }; - } - - return result; -}; - -const getSessionState = (sessionId) => { - if (!sessionId) return null; - return sessionStates.get(sessionId) || null; -}; - -// Session attention tracking - authoritative source for unread/needs-attention state -// Tracks which sessions need user attention based on activity and view state -const sessionAttentionStates = new Map(); // sessionId -> { -// needsAttention: boolean, -// lastUserMessageAt: number | null, -// lastStatusChangeAt: number, -// viewedByClients: Set, -// status: 'idle' | 'busy' | 'retry' -// } -const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - -const getOrCreateAttentionState = (sessionId) => { - if (!sessionId || typeof sessionId !== 'string') return null; - - let state = sessionAttentionStates.get(sessionId); - if (!state) { - state = { - needsAttention: false, - lastUserMessageAt: null, - lastStatusChangeAt: Date.now(), - viewedByClients: new Set(), - status: 'idle' - }; - sessionAttentionStates.set(sessionId, state); - } - return state; -}; - -const updateSessionAttentionStatus = (sessionId, status, eventId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - const prevStatus = state.status; - state.status = status; - state.lastStatusChangeAt = Date.now(); - - // Check if we need to mark as needsAttention - // Condition: transitioning from busy/retry to idle + user sent message + not currently viewed - // Note: The actual broadcast with needsAttention is done in updateSessionState - // to ensure both status and attention are sent in a single event - if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { - if (state.lastUserMessageAt && state.viewedByClients.size === 0) { - state.needsAttention = true; - } - } -}; - -const markSessionViewed = (sessionId, clientId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - const wasNeedsAttention = state.needsAttention; - state.viewedByClients.add(clientId); - - // Clear needsAttention when viewed - if (wasNeedsAttention) { - state.needsAttention = false; - - // Broadcast attention cleared event - if (uiNotificationClients.size > 0) { - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:session-status', - properties: { - sessionId, - status: state.status, - timestamp: Date.now(), - metadata: {}, - needsAttention: false - } - }); - } catch { - // Client disconnected - } - } - } - } -}; - -const markSessionUnviewed = (sessionId, clientId) => { - const state = sessionAttentionStates.get(sessionId); - if (!state) return; - - state.viewedByClients.delete(clientId); -}; - -const markUserMessageSent = (sessionId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - state.lastUserMessageAt = Date.now(); -}; - -const getSessionAttentionSnapshot = () => { - const result = {}; - const now = Date.now(); - - for (const [sessionId, state] of sessionAttentionStates) { - // Skip very old states - if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; - - result[sessionId] = { - needsAttention: state.needsAttention, - lastUserMessageAt: state.lastUserMessageAt, - lastStatusChangeAt: state.lastStatusChangeAt, - status: state.status, - isViewed: state.viewedByClients.size > 0 - }; - } - - return result; -}; - -const getSessionAttentionState = (sessionId) => { - if (!sessionId) return null; - const state = sessionAttentionStates.get(sessionId); - if (!state) return null; - - return { - needsAttention: state.needsAttention, - lastUserMessageAt: state.lastUserMessageAt, - lastStatusChangeAt: state.lastStatusChangeAt, - status: state.status, - isViewed: state.viewedByClients.size > 0 - }; -}; - -const cleanupOldSessionStates = () => { - const now = Date.now(); - let cleaned = 0; - - for (const [sessionId, data] of sessionStates) { - if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { - sessionStates.delete(sessionId); - cleaned++; - } - } - - // Also cleanup attention states - for (const [sessionId, state] of sessionAttentionStates) { - if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { - sessionAttentionStates.delete(sessionId); - cleaned++; - } - } - - if (cleaned > 0) { - console.info(`[SessionState] Cleaned up ${cleaned} old session states`); - } -}; - -// Start periodic cleanup -setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); - -const setSessionActivityPhase = (sessionId, phase) => { - if (!sessionId || typeof sessionId !== 'string') return false; - - const current = sessionActivityPhases.get(sessionId); - if (current?.phase === phase) return false; // No change - - // Match desktop semantics: only enter cooldown from busy. - if (phase === 'cooldown' && current?.phase !== 'busy') { - return false; - } - - // Cancel existing cooldown timer only on phase change. - const existingTimer = sessionActivityCooldowns.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionActivityCooldowns.delete(sessionId); - } - - sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); - - // Schedule transition from cooldown to idle - if (phase === 'cooldown') { - const timer = setTimeout(() => { - const now = sessionActivityPhases.get(sessionId); - if (now?.phase === 'cooldown') { - sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); - } - sessionActivityCooldowns.delete(sessionId); - }, SESSION_COOLDOWN_DURATION_MS); - sessionActivityCooldowns.set(sessionId, timer); - } - - return true; -}; - -const getSessionActivitySnapshot = () => { - const result = {}; - for (const [sessionId, data] of sessionActivityPhases) { - result[sessionId] = { type: data.phase }; - } - return result; -}; - -const resetAllSessionActivityToIdle = () => { - // Cancel all cooldown timers - for (const timer of sessionActivityCooldowns.values()) { - clearTimeout(timer); - } - sessionActivityCooldowns.clear(); - - // Reset all phases to idle - const now = Date.now(); - for (const [sessionId] of sessionActivityPhases) { - sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); - } -}; - -const resolveVapidSubject = async () => { - const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; - if (typeof configured === 'string' && configured.trim().length > 0) { - return configured.trim(); - } - - const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN; - if (typeof originEnv === 'string' && originEnv.trim().length > 0) { - const trimmed = originEnv.trim(); - // Convert http://localhost to mailto for VAPID compatibility - if (trimmed.startsWith('http://localhost')) { - return 'mailto:openchamber@localhost'; - } - return trimmed; - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const stored = settings?.publicOrigin; - if (typeof stored === 'string' && stored.trim().length > 0) { - const trimmed = stored.trim(); - // Convert http://localhost to mailto for VAPID compatibility - if (trimmed.startsWith('http://localhost')) { - return 'mailto:openchamber@localhost'; - } - return trimmed; - } - } catch { - // ignore - } - - return 'mailto:openchamber@localhost'; -}; - -const ensurePushInitialized = async () => { - if (pushInitialized) return; - const keys = await getOrCreateVapidKeys(); - const subject = await resolveVapidSubject(); - - if (subject === 'mailto:openchamber@localhost') { - console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.'); - } - - webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey); - pushInitialized = true; -}; - -const persistSettings = async (changes) => { - // Serialize concurrent calls using lock - persistSettingsLock = persistSettingsLock.then(async () => { - console.log(`[persistSettings] Called with changes:`, JSON.stringify(changes, null, 2)); - const current = await readSettingsFromDisk(); - console.log(`[persistSettings] Current projects count:`, Array.isArray(current.projects) ? current.projects.length : 'N/A'); - const sanitized = sanitizeSettingsUpdate(changes); - let next = mergePersistedSettings(current, sanitized); - - const normalizedState = normalizeSettingsPaths(next); - if (normalizedState.changed) { - next = normalizedState.settings; - } - - if (Array.isArray(next.projects)) { - console.log(`[persistSettings] Validating ${next.projects.length} projects...`); - const validated = await validateProjectEntries(next.projects); - console.log(`[persistSettings] After validation: ${validated.length} projects remain`); - next = { ...next, projects: validated }; - } - - if (Array.isArray(next.projects) && next.projects.length > 0) { - const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : ''; - const active = next.projects.find((project) => project.id === activeId) || null; - if (!active) { - console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`); - next = { ...next, activeProjectId: next.projects[0].id }; - } - } else if (next.activeProjectId) { - console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`); - next = { ...next, activeProjectId: undefined }; - } - - if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) { - await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets); - } - - if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) { - const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry])); - const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens) - .map(([presetId, token]) => { - const preset = presetsById.get(presetId); - if (!preset || typeof token !== 'string' || token.trim().length === 0) { - return null; - } - return { - id: preset.id, - name: preset.name, - hostname: preset.hostname, - token: token.trim(), - }; - }) - .filter(Boolean); - - for (const update of updates) { - await upsertManagedRemoteTunnelToken(update); - } - } - - await writeSettingsToDisk(next); - console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); - return formatSettingsResponse(next); - }); - - return persistSettingsLock; -}; +const sessionRuntime = createSessionRuntime({ + writeSseEvent, + getNotificationClients: () => uiNotificationClients, +}); // HMR-persistent state via globalThis // These values survive Vite HMR reloads to prevent zombie OpenCode processes -const HMR_STATE_KEY = '__openchamberHmrState'; -const getHmrState = () => { - if (!globalThis[HMR_STATE_KEY]) { - globalThis[HMR_STATE_KEY] = { - openCodeProcess: null, - openCodePort: null, - openCodeWorkingDirectory: os.homedir(), - isShuttingDown: false, - signalsAttached: false, - userProvidedOpenCodePassword: undefined, - openCodeAuthPassword: null, - openCodeAuthSource: null, - }; - } - return globalThis[HMR_STATE_KEY]; -}; -const hmrState = getHmrState(); - -const normalizeOpenCodePassword = (value) => { - if (typeof value !== 'string') { - return ''; - } - return value.trim(); -}; - -if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') { - const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD); - hmrState.userProvidedOpenCodePassword = initialPassword || null; -} +const hmrStateRuntime = createHmrStateRuntime({ + globalThisLike: globalThis, + os, + processLike: process, + stateKey: '__openchamberHmrState', +}); +const hmrState = hmrStateRuntime.getOrCreateHmrState(); +hmrStateRuntime.ensureUserProvidedOpenCodePassword(hmrState); // Non-HMR state (safe to reset on reload) let healthCheckInterval = null; let server = null; -let cachedModelsMetadata = null; -let cachedModelsMetadataTimestamp = 0; let expressApp = null; let currentRestartPromise = null; let isRestartingOpenCode = false; @@ -3636,48 +334,43 @@ tunnelProviderRegistry.seal(); const tunnelAuthController = createTunnelAuth(); let runtimeManagedRemoteTunnelToken = ''; let runtimeManagedRemoteTunnelHostname = ''; -let terminalInputWsServer = null; -const userProvidedOpenCodePassword = - typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 - ? hmrState.userProvidedOpenCodePassword - : null; -let openCodeAuthPassword = - typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 - ? hmrState.openCodeAuthPassword - : userProvidedOpenCodePassword; -let openCodeAuthSource = - typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 - ? hmrState.openCodeAuthSource - : (userProvidedOpenCodePassword ? 'user-env' : null); +let terminalRuntime = null; +const userProvidedOpenCodePassword = hmrStateRuntime.getUserProvidedOpenCodePassword(hmrState); +const initialOpenCodeAuthState = hmrStateRuntime.resolveOpenCodeAuthFromState({ + hmrState, + userProvidedOpenCodePassword, +}); +let openCodeAuthPassword = initialOpenCodeAuthState.openCodeAuthPassword; +let openCodeAuthSource = initialOpenCodeAuthState.openCodeAuthSource; // Sync helper - call after modifying any HMR state variable const syncToHmrState = () => { - hmrState.openCodeProcess = openCodeProcess; - hmrState.openCodePort = openCodePort; - hmrState.openCodeBaseUrl = openCodeBaseUrl; - hmrState.isShuttingDown = isShuttingDown; - hmrState.signalsAttached = signalsAttached; - hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory; - hmrState.openCodeAuthPassword = openCodeAuthPassword; - hmrState.openCodeAuthSource = openCodeAuthSource; + hmrStateRuntime.syncStateFromRuntime(hmrState, { + openCodeProcess, + openCodePort, + openCodeBaseUrl, + isShuttingDown, + signalsAttached, + openCodeWorkingDirectory, + openCodeAuthPassword, + openCodeAuthSource, + }); }; // Sync helper - call to restore state from HMR (e.g., on module reload) const syncFromHmrState = () => { - openCodeProcess = hmrState.openCodeProcess; - openCodePort = hmrState.openCodePort; - openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null; - isShuttingDown = hmrState.isShuttingDown; - signalsAttached = hmrState.signalsAttached; - openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; - openCodeAuthPassword = - typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 - ? hmrState.openCodeAuthPassword - : userProvidedOpenCodePassword; - openCodeAuthSource = - typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 - ? hmrState.openCodeAuthSource - : (userProvidedOpenCodePassword ? 'user-env' : null); + const restored = hmrStateRuntime.restoreRuntimeFromState({ + hmrState, + userProvidedOpenCodePassword, + }); + openCodeProcess = restored.openCodeProcess; + openCodePort = restored.openCodePort; + openCodeBaseUrl = restored.openCodeBaseUrl; + isShuttingDown = restored.isShuttingDown; + signalsAttached = restored.signalsAttached; + openCodeWorkingDirectory = restored.openCodeWorkingDirectory; + openCodeAuthPassword = restored.openCodeAuthPassword; + openCodeAuthSource = restored.openCodeAuthSource; }; // Module-level variables that shadow HMR state @@ -3689,119 +382,15 @@ let isShuttingDown = hmrState.isShuttingDown; let signalsAttached = hmrState.signalsAttached; let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; -/** - * Check if an existing OpenCode process is still alive and responding - * Used to reuse process across HMR reloads - */ -async function isOpenCodeProcessHealthy() { - if (!openCodeProcess || !openCodePort) { - return false; - } - - // Health check via HTTP since SDK object doesn't expose exitCode - try { - const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, { - method: 'GET', - headers: getOpenCodeAuthHeaders(), - signal: AbortSignal.timeout(2000), - }); - return response.ok; - } catch { - return false; - } -} - -/** - * Probe if an external OpenCode instance is already running on the given port. - * Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set. - * Used to auto-detect and connect to an existing OpenCode instance on startup. - */ -async function probeExternalOpenCode(port, origin) { - if (!port || port <= 0) { - return false; - } - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const base = origin ?? `http://127.0.0.1:${port}`; - const response = await fetch(`${base}/global/health`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: controller.signal, - }); - clearTimeout(timeout); - if (!response.ok) return false; - const body = await response.json().catch(() => null); - return body?.healthy === true; - } catch { - return false; - } -} - -const ENV_CONFIGURED_OPENCODE_PORT = (() => { - const raw = - process.env.OPENCODE_PORT || - process.env.OPENCHAMBER_OPENCODE_PORT || - process.env.OPENCHAMBER_INTERNAL_PORT; - if (!raw) { - return null; - } - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -})(); - -const ENV_CONFIGURED_OPENCODE_HOST = (() => { - const raw = process.env.OPENCODE_HOST?.trim(); - if (!raw) return null; - - const warnInvalidHost = (reason) => { - console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`); - }; - - let url; - try { - url = new URL(raw); - } catch { - warnInvalidHost('not a valid URL'); - return null; - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`); - return null; - } - const port = parseInt(url.port, 10); - if (!Number.isFinite(port) || port <= 0) { - warnInvalidHost('must include an explicit port (example: http://hostname:4096)'); - return null; - } - if (url.pathname !== '/' || url.search || url.hash) { - warnInvalidHost('must not include path, query, or hash'); - return null; - } - return { origin: url.origin, port }; -})(); - -// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set -const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_OPENCODE_PORT; - -const ENV_CONFIGURED_OPENCODE_HOSTNAME = (() => { - const raw = process.env.OPENCHAMBER_OPENCODE_HOSTNAME; - if (typeof raw !== 'string') { - return '127.0.0.1'; - } - const trimmed = raw.trim(); - if (!trimmed) { - console.warn( - `[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`, - ); - return '127.0.0.1'; - } - return trimmed; -})(); +const { + configuredOpenCodePort: ENV_CONFIGURED_OPENCODE_PORT, + configuredOpenCodeHost: ENV_CONFIGURED_OPENCODE_HOST, + effectivePort: ENV_EFFECTIVE_PORT, + configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, +} = resolveOpenCodeEnvConfig({ + env: process.env, + logger: console, +}); const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; @@ -3816,239 +405,45 @@ const ENV_CONFIGURED_OPENCODE_WSL_DISTRO = : null ); -// OpenCode server authentication (Basic Auth with username "opencode") +const openCodeAuthStateRuntime = createOpenCodeAuthStateRuntime({ + crypto, + process, + getAuthPassword: () => openCodeAuthPassword, + setAuthPassword: (value) => { + openCodeAuthPassword = value; + }, + getAuthSource: () => openCodeAuthSource, + setAuthSource: (value) => { + openCodeAuthSource = value; + }, + getUserProvidedPassword: () => userProvidedOpenCodePassword, + syncToHmrState, +}); -/** - * Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set. - * Uses Basic Auth with username "opencode" and the password from the env variable. - */ -function getOpenCodeAuthHeaders() { - const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || ''); - - if (!password) { - return {}; - } - - const credentials = Buffer.from(`opencode:${password}`).toString('base64'); - return { Authorization: `Basic ${credentials}` }; -} +const getOpenCodeAuthHeaders = (...args) => openCodeAuthStateRuntime.getOpenCodeAuthHeaders(...args); +const isOpenCodeConnectionSecure = (...args) => openCodeAuthStateRuntime.isOpenCodeConnectionSecure(...args); +const ensureLocalOpenCodeServerPassword = (...args) => openCodeAuthStateRuntime.ensureLocalOpenCodeServerPassword(...args); -function isOpenCodeConnectionSecure() { - return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization'); -} +const openCodeNetworkState = {}; +Object.defineProperties(openCodeNetworkState, { + openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, + openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, + openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, + openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, + openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, +}); -function generateSecureOpenCodePassword() { - return crypto - .randomBytes(32) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} +const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({ + state: openCodeNetworkState, + getOpenCodeAuthHeaders, +}); -function isValidOpenCodePassword(password) { - return typeof password === 'string' && password.trim().length > 0; -} - -function setOpenCodeAuthState(password, source) { - const normalized = normalizeOpenCodePassword(password); - if (!isValidOpenCodePassword(normalized)) { - openCodeAuthPassword = null; - openCodeAuthSource = null; - delete process.env.OPENCODE_SERVER_PASSWORD; - syncToHmrState(); - return null; - } - - openCodeAuthPassword = normalized; - openCodeAuthSource = source; - process.env.OPENCODE_SERVER_PASSWORD = normalized; - syncToHmrState(); - return normalized; -} - -async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) { - if (isValidOpenCodePassword(userProvidedOpenCodePassword)) { - return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env'); - } - - if (rotateManaged) { - const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated'); - console.log('Rotated secure password for managed local OpenCode instance'); - return rotatedPassword; - } - - if (isValidOpenCodePassword(openCodeAuthPassword)) { - return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated'); - } - - const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated'); - console.log('Generated secure password for managed local OpenCode instance'); - return generatedPassword; -} - -let cachedLoginShellEnvSnapshot = undefined; - -function parseNullSeparatedEnvSnapshot(raw) { - if (typeof raw !== 'string' || raw.length === 0) { - return null; - } - - const result = {}; - const entries = raw.split('\0'); - for (const entry of entries) { - if (!entry) { - continue; - } - const idx = entry.indexOf('='); - if (idx <= 0) { - continue; - } - const key = entry.slice(0, idx); - const value = entry.slice(idx + 1); - result[key] = value; - } - - return Object.keys(result).length > 0 ? result : null; -} - -function getLoginShellEnvSnapshot() { - if (cachedLoginShellEnvSnapshot !== undefined) { - return cachedLoginShellEnvSnapshot; - } - - if (process.platform === 'win32') { - const windowsSnapshot = getWindowsShellEnvSnapshot(); - cachedLoginShellEnvSnapshot = windowsSnapshot; - return windowsSnapshot; - } - - const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - - for (const shellPath of shellCandidates) { - if (!isExecutable(shellPath)) { - continue; - } - - try { - const result = spawnSync(shellPath, ['-lic', 'env -0'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - - if (result.status !== 0) { - continue; - } - - const parsed = parseNullSeparatedEnvSnapshot(result.stdout || ''); - if (parsed) { - cachedLoginShellEnvSnapshot = parsed; - return parsed; - } - } catch { - // ignore - } - } - - cachedLoginShellEnvSnapshot = null; - return null; -} - -function getWindowsShellEnvSnapshot() { - const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : ''); - - const psScript = - "Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }"; - - const powershellCandidates = [ - 'pwsh.exe', - 'powershell.exe', - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), - ]; - - for (const shellPath of powershellCandidates) { - try { - const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - if (result.status !== 0) { - continue; - } - const parsed = parseResult(result.stdout); - if (parsed) { - return parsed; - } - } catch { - // ignore - } - } - - const comspec = process.env.ComSpec || 'cmd.exe'; - try { - const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) { - return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0')); - } - } catch { - // ignore - } - - return null; -} - -function mergePathValues(preferred, fallback) { - const merged = new Set(); - - const addSegments = (value) => { - if (typeof value !== 'string' || !value) { - return; - } - for (const segment of value.split(path.delimiter)) { - if (segment) { - merged.add(segment); - } - } - }; - - addSegments(preferred); - addSegments(fallback); - - return Array.from(merged).join(path.delimiter); -} - -function applyLoginShellEnvSnapshot() { - const snapshot = getLoginShellEnvSnapshot(); - if (!snapshot) { - return; - } - - const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); - - for (const [key, value] of Object.entries(snapshot)) { - if (skipKeys.has(key)) { - continue; - } - const existing = process.env[key]; - if (typeof existing === 'string' && existing.length > 0) { - continue; - } - process.env[key] = value; - } - - process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || ''); -} - -applyLoginShellEnvSnapshot(); +const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args); +const normalizeApiPrefix = (...args) => openCodeNetworkRuntime.normalizeApiPrefix(...args); +const setDetectedOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.setDetectedOpenCodeApiPrefix(...args); +const buildOpenCodeUrl = (...args) => openCodeNetworkRuntime.buildOpenCodeUrl(...args); +const ensureOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.ensureOpenCodeApiPrefix(...args); +const scheduleOpenCodeApiDetection = (...args) => openCodeNetworkRuntime.scheduleOpenCodeApiDetection(...args); const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' @@ -4058,8 +453,7 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( console.warn('Ignoring configured OpenCode API prefix; API runs at root.'); } -let globalEventWatcherAbortController = null; - +let cachedLoginShellEnvSnapshot; let resolvedOpencodeBinary = null; let resolvedOpencodeBinarySource = null; let resolvedNodeBinary = null; @@ -4070,3051 +464,320 @@ let resolvedWslBinary = null; let resolvedWslOpencodePath = null; let resolvedWslDistro = null; -function resolveGitBinaryForSpawn() { - if (process.platform !== 'win32') { - return 'git'; - } - - if (resolvedGitBinary) { - return resolvedGitBinary; - } - - const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter(Boolean); - for (const candidate of explicit) { - if (isExecutable(candidate)) { - resolvedGitBinary = candidate; - return resolvedGitBinary; - } - } - - const candidates = []; - const normalizeGitCandidate = (candidate) => { - if (typeof candidate !== 'string') { - return ''; - } - const trimmed = candidate.trim(); - if (!trimmed) { - return ''; - } - const ext = path.extname(trimmed).toLowerCase(); - if (ext === '.cmd' || ext === '.bat' || ext === '.com') { - const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; - if (isExecutable(exeCandidate)) { - return exeCandidate; - } - } - return trimmed; - }; - - const pathCandidate = normalizeGitCandidate(searchPathFor('git')); - if (pathCandidate && isExecutable(pathCandidate)) { - candidates.push(pathCandidate); - } - - const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe')); - if (pathExeCandidate && isExecutable(pathExeCandidate)) { - candidates.push(pathExeCandidate); - } - - const programRoots = [ - process.env.ProgramFiles, - process.env['ProgramFiles(x86)'], - process.env.LocalAppData, - ] - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter(Boolean); - for (const root of programRoots) { - const installCandidates = [ - path.join(root, 'Git', 'cmd', 'git.exe'), - path.join(root, 'Git', 'bin', 'git.exe'), - path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'), - path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'), - path.join(root, 'Programs', 'Git', 'bin', 'git.exe'), - ]; - for (const candidate of installCandidates) { - const normalized = normalizeGitCandidate(candidate); - if (normalized && isExecutable(normalized)) { - candidates.push(normalized); - } - } - } - - const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe')); - resolvedGitBinary = preferredExe || candidates[0] || 'git.exe'; - return resolvedGitBinary; -} - -function isExecutable(filePath) { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) return false; - if (process.platform === 'win32') { - const ext = path.extname(filePath).toLowerCase(); - if (!ext) return true; - return ['.exe', '.cmd', '.bat', '.com'].includes(ext); - } - fs.accessSync(filePath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -function prependToPath(dir) { - const trimmed = typeof dir === 'string' ? dir.trim() : ''; - if (!trimmed) return; - const current = process.env.PATH || ''; - const parts = current.split(path.delimiter).filter(Boolean); - if (parts.includes(trimmed)) return; - process.env.PATH = [trimmed, ...parts].join(path.delimiter); -} - -function searchPathFor(binaryName) { - const current = process.env.PATH || ''; - const parts = current.split(path.delimiter).filter(Boolean); - for (const dir of parts) { - const candidate = path.join(dir, binaryName); - if (isExecutable(candidate)) { - return candidate; - } - } - return null; -} - -function isWslExecutableValue(value) { - if (typeof value !== 'string') return false; - const trimmed = value.trim(); - if (!trimmed) return false; - return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); -} - -function clearWslOpencodeResolution() { - useWslForOpencode = false; - resolvedWslBinary = null; - resolvedWslOpencodePath = null; - resolvedWslDistro = null; -} - -function resolveWslExecutablePath() { - if (process.platform !== 'win32') { - return null; - } - - const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - try { - const result = spawnSync('where', ['wsl'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) { - return found; - } - } - } catch { - // ignore - } - - const systemRoot = process.env.SystemRoot || 'C:\\Windows'; - const fallback = path.join(systemRoot, 'System32', 'wsl.exe'); - if (isExecutable(fallback)) { - return fallback; - } - - return null; -} - -function buildWslExecArgs(execArgs, distroOverride = null) { - const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0 - ? distroOverride.trim() - : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; - - const prefix = distro ? ['-d', distro] : []; - return [...prefix, '--exec', ...execArgs]; -} - -function probeWslForOpencode() { - if (process.platform !== 'win32') { - return null; - } - - const wslBinary = resolveWslExecutablePath(); - if (!wslBinary) { - return null; - } - - try { - const result = spawnSync( - wslBinary, - buildWslExecArgs(['sh', '-lc', 'command -v opencode']), - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 6000, - windowsHide: true, - }, - ); - - if (result.status !== 0) { - return null; - } - - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines[0] || ''; - if (!found) { - return null; - } - - return { - wslBinary, - opencodePath: found, - distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }; - } catch { - return null; - } -} - -function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) { - const resolvedWsl = wslBinary || resolveWslExecutablePath(); - if (!resolvedWsl) { - return null; - } - - useWslForOpencode = true; - resolvedWslBinary = resolvedWsl; - resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0 - ? opencodePath.trim() - : 'opencode'; - resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; - resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`; - resolvedOpencodeBinarySource = source; - - // Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts. - delete process.env.OPENCODE_BINARY; - return resolvedOpencodeBinary; -} - -function resolveOpencodeCliPath() { - const explicit = [ - process.env.OPENCODE_BINARY, - process.env.OPENCODE_PATH, - process.env.OPENCHAMBER_OPENCODE_PATH, - process.env.OPENCHAMBER_OPENCODE_BIN, - ] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'env'; - return candidate; - } - } - - const resolvedFromPath = searchPathFor('opencode'); - if (resolvedFromPath) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'path'; - return resolvedFromPath; - } - - const home = os.homedir(); - const unixFallbacks = [ - path.join(home, '.opencode', 'bin', 'opencode'), - path.join(home, '.bun', 'bin', 'opencode'), - path.join(home, '.local', 'bin', 'opencode'), - path.join(home, 'bin', 'opencode'), - '/opt/homebrew/bin/opencode', - '/usr/local/bin/opencode', - '/usr/bin/opencode', - '/bin/opencode', - ]; - - const winFallbacks = (() => { - const userProfile = process.env.USERPROFILE || home; - const appData = process.env.APPDATA || ''; - const localAppData = process.env.LOCALAPPDATA || ''; - const programData = process.env.ProgramData || 'C:\\ProgramData'; - - return [ - path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), - path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), - path.join(appData, 'npm', 'opencode.cmd'), - path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'), - path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), - path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), - path.join(userProfile, '.bun', 'bin', 'opencode.exe'), - path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', - ].filter(Boolean); - })(); - - const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; - for (const candidate of fallbacks) { - if (isExecutable(candidate)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'fallback'; - return candidate; - } - } - - if (process.platform === 'win32') { - try { - const result = spawnSync('where', ['opencode'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'where'; - return found; - } - } - } catch { - // ignore - } - const wsl = probeWslForOpencode(); - if (wsl) { - return applyWslOpencodeResolution({ - wslBinary: wsl.wslBinary, - opencodePath: wsl.opencodePath, - source: 'wsl', - distro: wsl.distro, - }); - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v opencode'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'shell'; - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function resolveNodeCliPath() { - const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - const resolvedFromPath = searchPathFor('node'); - if (resolvedFromPath) { - return resolvedFromPath; - } - - const unixFallbacks = [ - '/opt/homebrew/bin/node', - '/usr/local/bin/node', - '/usr/bin/node', - '/bin/node', - ]; - for (const candidate of unixFallbacks) { - if (isExecutable(candidate)) { - return candidate; - } - } - - if (process.platform === 'win32') { - try { - const result = spawnSync('where', ['node'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) return found; - } - } catch { - // ignore - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v node'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function resolveBunCliPath() { - const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - const resolvedFromPath = searchPathFor('bun'); - if (resolvedFromPath) { - return resolvedFromPath; - } - - const home = os.homedir(); - const unixFallbacks = [ - path.join(home, '.bun', 'bin', 'bun'), - '/opt/homebrew/bin/bun', - '/usr/local/bin/bun', - '/usr/bin/bun', - '/bin/bun', - ]; - for (const candidate of unixFallbacks) { - if (isExecutable(candidate)) { - return candidate; - } - } - - if (process.platform === 'win32') { - const userProfile = process.env.USERPROFILE || home; - const winFallbacks = [ - path.join(userProfile, '.bun', 'bin', 'bun.exe'), - path.join(userProfile, '.bun', 'bin', 'bun.cmd'), - ]; - for (const candidate of winFallbacks) { - if (isExecutable(candidate)) return candidate; - } - - try { - const result = spawnSync('where', ['bun'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) return found; - } - } catch { - // ignore - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v bun'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function ensureBunCliEnv() { - if (resolvedBunBinary) { - return resolvedBunBinary; - } - - const resolved = resolveBunCliPath(); - if (resolved) { - prependToPath(path.dirname(resolved)); - resolvedBunBinary = resolved; - return resolved; - } - - return null; -} - -function ensureNodeCliEnv() { - if (resolvedNodeBinary) { - return resolvedNodeBinary; - } - - const resolved = resolveNodeCliPath(); - if (resolved) { - prependToPath(path.dirname(resolved)); - resolvedNodeBinary = resolved; - return resolved; - } - - return null; -} - -function readShebang(opencodePath) { - if (!opencodePath || typeof opencodePath !== 'string') { - return null; - } - try { - // Best effort: detect "#!/usr/bin/env " without reading whole file. - const fd = fs.openSync(opencodePath, 'r'); - try { - const buf = Buffer.alloc(256); - const bytes = fs.readSync(fd, buf, 0, buf.length, 0); - const head = buf.subarray(0, bytes).toString('utf8'); - const firstLine = head.split(/\r?\n/, 1)[0] || ''; - if (!firstLine.startsWith('#!')) { - return null; - } - const shebang = firstLine.slice(2).trim(); - if (!shebang) { - return null; - } - return shebang; - } finally { - try { - fs.closeSync(fd); - } catch { - // ignore - } - } - } catch { - return null; - } -} - -function opencodeShimInterpreter(opencodePath) { - const shebang = readShebang(opencodePath); - if (!shebang) return null; - if (/\bnode\b/i.test(shebang)) return 'node'; - if (/\bbun\b/i.test(shebang)) return 'bun'; - return null; -} - -function ensureOpencodeShimRuntime(opencodePath) { - const runtime = opencodeShimInterpreter(opencodePath); - if (runtime === 'node') { - ensureNodeCliEnv(); - } - if (runtime === 'bun') { - ensureBunCliEnv(); - } -} - -function normalizeOpencodeBinarySetting(raw) { - if (typeof raw !== 'string') { - return null; - } - const trimmed = normalizeDirectoryPath(raw).trim(); - if (!trimmed) { - return ''; - } - - try { - const stat = fs.statSync(trimmed); - if (stat.isDirectory()) { - const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; - return path.join(trimmed, bin); - } - } catch { - // ignore - } - - return trimmed; -} - -async function applyOpencodeBinaryFromSettings() { - try { - const settings = await readSettingsFromDiskMigrated(); - if (!settings || typeof settings !== 'object') { - return null; - } - if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { - return null; - } - - const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); - - if (normalized === '') { - delete process.env.OPENCODE_BINARY; - resolvedOpencodeBinary = null; - resolvedOpencodeBinarySource = null; - clearWslOpencodeResolution(); - return null; - } - - const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; - - const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' - ? raw.match(/^wsl:\s*(.+)$/i) - : null; - - if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { - const probe = probeWslForOpencode(); - const applied = applyWslOpencodeResolution({ - wslBinary: probe?.wslBinary || resolveWslExecutablePath(), - opencodePath: explicitWslPath[1].trim(), - source: 'settings-wsl-path', - distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }); - if (applied) { - return applied; - } - } - - if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { - const probe = probeWslForOpencode(); - const applied = applyWslOpencodeResolution({ - wslBinary: probe?.wslBinary || normalized || raw || null, - opencodePath: probe?.opencodePath || 'opencode', - source: 'settings-wsl', - distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }); - if (applied) { - return applied; - } - } - - if (normalized && isExecutable(normalized)) { - clearWslOpencodeResolution(); - process.env.OPENCODE_BINARY = normalized; - prependToPath(path.dirname(normalized)); - resolvedOpencodeBinary = normalized; - resolvedOpencodeBinarySource = 'settings'; - ensureOpencodeShimRuntime(normalized); - return normalized; - } - - if (raw) { - console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); - } - } catch { - // ignore - } - - return null; -} - -function ensureOpencodeCliEnv() { - if (resolvedOpencodeBinary) { - if (useWslForOpencode) { - return resolvedOpencodeBinary; - } - ensureOpencodeShimRuntime(resolvedOpencodeBinary); - return resolvedOpencodeBinary; - } - - const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; - if (existing && isExecutable(existing)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinary = existing; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env'; - prependToPath(path.dirname(existing)); - ensureOpencodeShimRuntime(existing); - return resolvedOpencodeBinary; - } - - const resolved = resolveOpencodeCliPath(); - if (resolved) { - if (useWslForOpencode) { - resolvedOpencodeBinary = resolved; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl'; - console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`); - return resolved; - } - - process.env.OPENCODE_BINARY = resolved; - prependToPath(path.dirname(resolved)); - ensureOpencodeShimRuntime(resolved); - resolvedOpencodeBinary = resolved; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'unknown'; - console.log(`Resolved opencode CLI: ${resolved}`); - return resolved; - } - - clearWslOpencodeResolution(); - return null; -} - -const startGlobalEventWatcher = async () => { - if (globalEventWatcherAbortController) { - return; - } - - await waitForOpenCodePort(); - - globalEventWatcherAbortController = new AbortController(); - const signal = globalEventWatcherAbortController.signal; - - let attempt = 0; - - const run = async () => { - while (!signal.aborted) { - attempt += 1; - let upstream; - let reader; - try { - const url = buildOpenCodeUrl('/global/event', ''); - upstream = await fetch(url, { - headers: { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }, - signal, - }); - - if (!upstream.ok || !upstream.body) { - throw new Error(`bad status ${upstream.status}`); - } - - console.log('[PushWatcher] connected'); - - const decoder = new TextDecoder(); - reader = upstream.body.getReader(); - let buffer = ''; - - while (!signal.aborted) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - separatorIndex = buffer.indexOf('\n\n'); - const payload = parseSseDataPayload(block); - // Cache session titles from session.updated/session.created events - maybeCacheSessionInfoFromEvent(payload); - void maybeSendPushForTrigger(payload); - // Track session activity independently of UI (mirrors Tauri desktop behavior) - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - setSessionActivityPhase(activity.sessionId, activity.phase); - } - } - - // Update authoritative session state from OpenCode events - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - } - } - } catch (error) { - if (signal.aborted) { - return; - } - console.warn('[PushWatcher] disconnected', error?.message ?? error); - } finally { - try { - if (reader) { - await reader.cancel(); - reader.releaseLock(); - } else if (upstream?.body && !upstream.body.locked) { - await upstream.body.cancel(); - } - } catch { - // ignore - } - } - - const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); - await new Promise((r) => setTimeout(r, backoffMs)); - } - }; - - void run(); -}; - -const stopGlobalEventWatcher = () => { - if (!globalEventWatcherAbortController) { - return; - } - try { - globalEventWatcherAbortController.abort(); - } catch { - // ignore - } - globalEventWatcherAbortController = null; -}; - - -function setOpenCodePort(port) { - if (!Number.isFinite(port) || port <= 0) { - return; - } - - const numericPort = Math.trunc(port); - const portChanged = openCodePort !== numericPort; - - if (portChanged || openCodePort === null) { - openCodePort = numericPort; - syncToHmrState(); - console.log(`Detected OpenCode port: ${openCodePort}`); - - if (portChanged) { - isOpenCodeReady = false; - } - openCodeNotReadySince = Date.now(); - } - - lastOpenCodeError = null; -} - -async function waitForOpenCodePort(timeoutMs = 15000) { - if (openCodePort !== null) { - return openCodePort; - } - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 50)); - if (openCodePort !== null) { - return openCodePort; - } - } - - throw new Error('Timed out waiting for OpenCode port'); -} - -function getLoginShellPath() { - const snapshot = getLoginShellEnvSnapshot(); - if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) { - return null; - } - return snapshot.PATH; -} - -function buildAugmentedPath() { - const augmented = new Set(); - - const loginShellPath = getLoginShellPath(); - if (loginShellPath) { - for (const segment of loginShellPath.split(path.delimiter)) { - if (segment) { - augmented.add(segment); - } - } - } - - const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean); - for (const segment of current) { - augmented.add(segment); - } - - return Array.from(augmented).join(path.delimiter); -} - -const API_PREFIX_CANDIDATES = ['']; - -async function waitForReady(url, timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const res = await fetch(`${url.replace(/\/+$/, '')}/global/health`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: controller.signal - }); - clearTimeout(timeout); - - if (res.ok) { - const body = await res.json().catch(() => null); - if (body?.healthy === true) { - return true; - } - } - } catch { - // ignore - } - await new Promise(r => setTimeout(r, 100)); - } - return false; -} - -function normalizeApiPrefix(prefix) { - if (!prefix) { - return ''; - } - - if (prefix.includes('://')) { - try { - const parsed = new URL(prefix); - return normalizeApiPrefix(parsed.pathname); - } catch (error) { - return ''; - } - } - - const trimmed = prefix.trim(); - if (!trimmed || trimmed === '/') { - return ''; - } - const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; -} - -function setDetectedOpenCodeApiPrefix() { - openCodeApiPrefix = ''; - openCodeApiPrefixDetected = true; - if (openCodeApiDetectionTimer) { - clearTimeout(openCodeApiDetectionTimer); - openCodeApiDetectionTimer = null; - } -} - -function getCandidateApiPrefixes() { - return API_PREFIX_CANDIDATES; -} - -function buildOpenCodeUrl(path, prefixOverride) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); - const fullPath = `${prefix}${normalizedPath}`; - const base = openCodeBaseUrl ?? `http://localhost:${openCodePort}`; - return `${base}${fullPath}`; -} - -function parseSseDataPayload(block) { - if (!block || typeof block !== 'string') { - return null; - } - const dataLines = block - .split('\n') - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).replace(/^\s/, '')); - - if (dataLines.length === 0) { - return null; - } - - const payloadText = dataLines.join('\n').trim(); - if (!payloadText) { - return null; - } - - try { - const parsed = JSON.parse(payloadText); - if ( - parsed && - typeof parsed === 'object' && - typeof parsed.payload === 'object' && - parsed.payload !== null - ) { - return parsed.payload; - } - return parsed; - } catch { - return null; - } -} - -function extractSessionStatusUpdate(payload) { - if (!payload || typeof payload !== 'object' || payload.type !== 'session.status') { - return null; - } - - const props = payload.properties ?? {}; - const status = - props.status ?? - props.session?.status ?? - props.sessionInfo?.status; - const metadata = - props.metadata ?? - (typeof status === 'object' && status !== null ? status.metadata : null); - - const sessionId = props.sessionID ?? props.sessionId; - if (typeof sessionId !== 'string' || sessionId.length === 0) { - return null; - } - - const statusType = - typeof status === 'string' - ? status - : typeof status?.type === 'string' - ? status.type - : typeof status?.status === 'string' - ? status.status - : typeof props.type === 'string' - ? props.type - : typeof props.phase === 'string' - ? props.phase - : typeof props.state === 'string' - ? props.state - : null; - - const normalizedType = - statusType === 'idle' || statusType === 'busy' || statusType === 'retry' - ? statusType - : null; - - if (!normalizedType) { - return null; - } - - const attempt = - typeof status?.attempt === 'number' - ? status.attempt - : typeof props.attempt === 'number' - ? props.attempt - : typeof metadata?.attempt === 'number' - ? metadata.attempt - : undefined; - const message = - typeof status?.message === 'string' - ? status.message - : typeof props.message === 'string' - ? props.message - : typeof metadata?.message === 'string' - ? metadata.message - : undefined; - const next = - typeof status?.next === 'number' - ? status.next - : typeof props.next === 'number' - ? props.next - : typeof metadata?.next === 'number' - ? metadata.next - : undefined; - - return { - sessionId, - type: normalizedType, - attempt, - message, - next, - eventId: typeof props.eventId === 'string' ? props.eventId : null, - }; -} - -function emitDesktopNotification(payload) { - if (!ENV_DESKTOP_NOTIFY) { - return; - } - - if (!payload || typeof payload !== 'object') { - return; - } - - try { - // One-line protocol consumed by the Tauri shell. - process.stdout.write(`${DESKTOP_NOTIFY_PREFIX}${JSON.stringify(payload)}\n`); - } catch { - // ignore - } -} - -function broadcastUiNotification(payload) { - if (!payload || typeof payload !== 'object') { - return; - } - - if (uiNotificationClients.size === 0) { - return; - } - - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:notification', - properties: { - ...payload, - // Tell the UI whether the sidecar stdout notification channel is active. - // When true, the desktop UI should skip this SSE notification to avoid duplicates. - // When false (e.g. tauri dev), the UI must handle this SSE notification itself. - desktopStdoutActive: ENV_DESKTOP_NOTIFY, - }, - }); - } catch { - // ignore - } - } -} - -function isStreamingAssistantPart(properties) { - if (!properties || typeof properties !== 'object') { - return false; - } - - const info = properties?.info; - const role = info?.role; - if (role !== 'assistant') { - return false; - } - - const part = properties?.part; - const partType = part?.type; - return ( - partType === 'step-start' || - partType === 'text' || - partType === 'tool' || - partType === 'reasoning' || - partType === 'file' || - partType === 'patch' - ); -} - -function deriveSessionActivityTransitions(payload) { - if (!payload || typeof payload !== 'object') { - return []; - } - - if (payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - const phase = update.type === 'busy' || update.type === 'retry' ? 'busy' : 'idle'; - return [{ sessionId: update.sessionId, phase }]; - } - } - - if (payload.type === 'message.updated') { - const info = payload.properties?.info; - const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; - const role = info?.role; - const finish = info?.finish; - if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { - return [{ sessionId, phase: 'cooldown' }]; - } - } - - if (payload.type === 'message.part.updated' || payload.type === 'message.part.delta') { - const info = payload.properties?.info; - const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; - const role = info?.role; - const finish = info?.finish; - - if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant') { - const transitions = []; - - // Desktop parity: mark busy when we see assistant parts streaming. - if (isStreamingAssistantPart(payload.properties)) { - transitions.push({ sessionId, phase: 'busy' }); - } - - // Desktop parity: enter cooldown when finish==stop. - if (finish === 'stop') { - transitions.push({ sessionId, phase: 'cooldown' }); - } - - return transitions; - } - } - - if (payload.type === 'session.idle') { - const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId; - if (typeof sessionId === 'string' && sessionId.length > 0) { - return [{ sessionId, phase: 'idle' }]; - } - } - - return []; -} - -const PUSH_READY_COOLDOWN_MS = 5000; -const PUSH_QUESTION_DEBOUNCE_MS = 500; -const PUSH_PERMISSION_DEBOUNCE_MS = 500; -const pushQuestionDebounceTimers = new Map(); -const pushPermissionDebounceTimers = new Map(); -const notifiedPermissionRequests = new Set(); -const lastReadyNotificationAt = new Map(); - -// Cache: sessionId -> parentID (string) or null (no parent). Undefined = unknown. -const sessionParentIdCache = new Map(); -const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; - -const getCachedSessionParentId = (sessionId) => { - const entry = sessionParentIdCache.get(sessionId); - if (!entry) return undefined; - if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) { - sessionParentIdCache.delete(sessionId); - return undefined; - } - return entry.parentID; -}; - -const setCachedSessionParentId = (sessionId, parentID) => { - sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() }); -}; - -const fetchSessionParentId = async (sessionId) => { - if (!sessionId) return undefined; - - const cached = getCachedSessionParentId(sessionId); - if (cached !== undefined) return cached; - - try { - const response = await fetch(buildOpenCodeUrl('/session', ''), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(2000), - }); - if (!response.ok) { - return undefined; - } - const data = await response.json().catch(() => null); - if (!Array.isArray(data)) { - return undefined; - } - - const match = data.find((s) => s && typeof s === 'object' && s.id === sessionId); - const parentID = match?.parentID ? match.parentID : null; - setCachedSessionParentId(sessionId, parentID); - return parentID; - } catch { - return undefined; - } -}; - -const extractSessionIdFromPayload = (payload) => { - if (!payload || typeof payload !== 'object') return null; - const props = payload.properties; - const info = props?.info; - const sessionId = - info?.sessionID ?? - info?.sessionId ?? - props?.sessionID ?? - props?.sessionId ?? - props?.session ?? - null; - return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null; -}; - -const maybeSendPushForTrigger = async (payload) => { - if (!payload || typeof payload !== 'object') { - return; - } - - const sessionId = extractSessionIdFromPayload(payload); - - const formatMode = (raw) => { - const value = typeof raw === 'string' ? raw.trim() : ''; - const normalized = value.length > 0 ? value : 'agent'; - return normalized - .split(/[-_\s]+/) - .filter(Boolean) - .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) - .join(' '); - }; - - const formatModelId = (raw) => { - const value = typeof raw === 'string' ? raw.trim() : ''; - if (!value) { - return 'Assistant'; - } - - const tokens = value.split(/[-_]+/).filter(Boolean); - const result = []; - for (let i = 0; i < tokens.length; i += 1) { - const current = tokens[i]; - const next = tokens[i + 1]; - if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) { - result.push(`${current}.${next}`); - i += 1; - continue; - } - result.push(current); - } - - return result - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); - }; - - if (payload.type === 'message.updated') { - const info = payload.properties?.info; - if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { - // Check if this is a subtask and if we should notify for subtasks - const settings = await readSettingsFromDisk(); - - if (settings.notifyOnSubtasks === false) { - // Prefer parentID on payload (if present), else fetch from sessions list. - const sessionInfo = payload.properties?.session; - const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID; - const parentID = parentIDFromPayload - ? parentIDFromPayload - : await fetchSessionParentId(sessionId); - - // Fail open: if parentID cannot be resolved, send notification. - if (parentID) { - return; - } - } - - // Check if completion notifications are enabled - if (settings.notifyOnCompletion === false) { - return; - } - - const now = Date.now(); - const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0; - if (now - lastAt < PUSH_READY_COOLDOWN_MS) { - return; - } - lastReadyNotificationAt.set(sessionId, now); - - // Resolve templates with fallback to legacy hardcoded values - let title = `${formatMode(info?.mode)} agent is ready`; - let body = `${formatModelId(info?.modelID)} completed the task`; - - try { - const templates = settings.notificationTemplates || {}; - const isSubtask = await fetchSessionParentId(sessionId); - const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false - ? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }) - : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); - - const variables = await buildTemplateVariables(payload, sessionId); - - // Try fast-path (inline parts) first, then fetch from API - const messageId = info?.id; - let lastMessage = extractLastMessageText(payload); - if (!lastMessage) { - lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); - } - - const notifZenModel = await resolveZenModel(settings?.zenModel); - variables.last_message = await prepareNotificationLastMessage({ - message: lastMessage, - settings, - summarize: (text, len) => summarizeText(text, len, notifZenModel), - }); - - const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - const notificationPayload = { - title, - body, - tag: `ready-${sessionId}`, - kind: 'ready', - sessionId, - requireHidden: settings.notificationMode !== 'always', - }; - emitDesktopNotification(notificationPayload); - broadcastUiNotification(notificationPayload); - } - - await sendPushToAllUiSessions( - { - title, - body, - tag: `ready-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'ready', - } - }, - { requireNoSse: true } - ); - } - - // Check for error finish - if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) { - const settings = await readSettingsFromDisk(); - if (settings.notifyOnError === false) return; - - let title = 'Tool error'; - let body = 'An error occurred'; - - try { - const variables = await buildTemplateVariables(payload, sessionId); - - // Try fast-path (inline parts) first, then fetch from API - const errorMessageId = info?.id; - let lastMessage = extractLastMessageText(payload); - if (!lastMessage) { - lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); - } - - const errZenModel = await resolveZenModel(settings?.zenModel); - variables.last_message = await prepareNotificationLastMessage({ - message: lastMessage, - settings, - summarize: (text, len) => summarizeText(text, len, errZenModel), - }); - - const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; - const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Error template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - const notificationPayload = { - title, - body, - tag: `error-${sessionId}`, - kind: 'error', - sessionId, - requireHidden: settings.notificationMode !== 'always', - }; - emitDesktopNotification(notificationPayload); - broadcastUiNotification(notificationPayload); - } - - await sendPushToAllUiSessions( - { - title, - body, - tag: `error-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'error', - } - }, - { requireNoSse: true } - ); - } - - return; - } - - - if (payload.type === 'question.asked' && sessionId) { - const existingTimer = pushQuestionDebounceTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - pushQuestionDebounceTimers.delete(sessionId); - - const settings = await readSettingsFromDisk(); - - // Check if question notifications are enabled - if (settings.notifyOnQuestion === false) { - return; - } - - if (!settings.nativeNotificationsEnabled) { - // Still send push even if native notifications are disabled - } - - const firstQuestion = payload.properties?.questions?.[0]; - const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; - const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; - - // Legacy fallback title - let title = /plan\s*mode/i.test(header) - ? 'Switch to plan mode' - : /build\s*agent/i.test(header) - ? 'Switch to build mode' - : header || 'Input needed'; - let body = questionText || 'Agent is waiting for your response'; - - try { - // Build template variables - const variables = await buildTemplateVariables(payload, sessionId); - variables.last_message = questionText || header || ''; - - // Get question template - const templates = settings.notificationTemplates || {}; - const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' }; - - // Resolve templates with fallback to legacy behavior - const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Question template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - emitDesktopNotification({ - kind: 'question', - title, - body, - tag: `question-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - - broadcastUiNotification({ - kind: 'question', - title, - body, - tag: `question-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - } - - void sendPushToAllUiSessions( - { - title, - body, - tag: `question-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'question', - } - }, - { requireNoSse: true } - ); - }, PUSH_QUESTION_DEBOUNCE_MS); - - pushQuestionDebounceTimers.set(sessionId, timer); - return; - } - - if (payload.type === 'permission.asked' && sessionId) { - const requestId = payload.properties?.id; - const permission = payload.properties?.permission; - const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; - if (requestKey && notifiedPermissionRequests.has(requestKey)) { - return; - } - - const existingTimer = pushPermissionDebounceTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - pushPermissionDebounceTimers.delete(sessionId); - const settings = await readSettingsFromDisk(); - - // Permission requests use the question event toggle (since permission requests are a type of "agent needs input") - if (settings.notifyOnQuestion === false) { - return; - } - - if (!settings.nativeNotificationsEnabled) { - // Still send push even if native notifications are disabled - } - - const sessionTitle = payload.properties?.sessionTitle; - const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : ''; - const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0 - ? sessionTitle.trim() - : permissionText || 'Agent is waiting for your approval'; - - let title = 'Permission required'; - let body = fallbackMessage; - - try { - // Build template variables - const variables = await buildTemplateVariables(payload, sessionId); - variables.last_message = fallbackMessage; - - // Get question template (permission uses question template since it's an input request) - const templates = settings.notificationTemplates || {}; - const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' }; - - // Resolve templates with fallback to legacy behavior - const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Permission template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - emitDesktopNotification({ - kind: 'permission', - title, - body, - tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - - broadcastUiNotification({ - kind: 'permission', - title, - body, - tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - } - - if (requestKey) { - notifiedPermissionRequests.add(requestKey); - } - - void sendPushToAllUiSessions( - { - title, - body, - tag: `permission-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'permission', - } - }, - { requireNoSse: true } - ); - }, PUSH_PERMISSION_DEBOUNCE_MS); - - pushPermissionDebounceTimers.set(sessionId, timer); - } -}; - -function writeSseEvent(res, payload) { - res.write(`data: ${JSON.stringify(payload)}\n\n`); -} - -function extractApiPrefixFromUrl() { - return ''; -} - -function detectOpenCodeApiPrefix() { - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - return true; -} - -function ensureOpenCodeApiPrefix() { - return detectOpenCodeApiPrefix(); -} - -function scheduleOpenCodeApiDetection() { - return; -} - -function parseArgs(argv = process.argv.slice(2)) { - const args = Array.isArray(argv) ? [...argv] : []; - const envPassword = - process.env.OPENCHAMBER_UI_PASSWORD || - process.env.OPENCODE_UI_PASSWORD || - null; - const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; - const envTunnelProvider = process.env.OPENCHAMBER_TUNNEL_PROVIDER || undefined; - const envTunnelMode = process.env.OPENCHAMBER_TUNNEL_MODE || undefined; - const envTunnelConfigRaw = process.env.OPENCHAMBER_TUNNEL_CONFIG; - const envTunnelConfig = typeof envTunnelConfigRaw === 'string' - ? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null) - : undefined; - const envTunnelToken = process.env.OPENCHAMBER_TUNNEL_TOKEN || undefined; - const envTunnelHostname = process.env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined; - - const options = { - port: DEFAULT_PORT, - host: undefined, - uiPassword: envPassword, - tryCfTunnel: envCfTunnel, - tunnelProvider: envTunnelProvider, - tunnelMode: envTunnelMode, - tunnelConfigPath: envTunnelConfig, - tunnelToken: envTunnelToken, - tunnelHostname: envTunnelHostname, - }; - - const consumeValue = (currentIndex, inlineValue) => { - if (typeof inlineValue === 'string') { - return { value: inlineValue, nextIndex: currentIndex }; - } - const nextArg = args[currentIndex + 1]; - if (typeof nextArg === 'string' && !nextArg.startsWith('--')) { - return { value: nextArg, nextIndex: currentIndex + 1 }; - } - return { value: undefined, nextIndex: currentIndex }; - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (!arg.startsWith('--')) { - continue; - } - - const eqIndex = arg.indexOf('='); - const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2); - const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined; - - if (optionName === 'port' || optionName === 'p') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - const parsedPort = parseInt(value ?? '', 10); - options.port = Number.isFinite(parsedPort) ? parsedPort : DEFAULT_PORT; - continue; - } - - if (optionName === 'host') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; - continue; - } - - if (optionName === 'ui-password') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.uiPassword = typeof value === 'string' ? value : ''; - continue; - } - - if (optionName === 'try-cf-tunnel') { - options.tryCfTunnel = true; - continue; - } - - if (optionName === 'tunnel-provider') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider; - continue; - } - - if (optionName === 'tunnel-mode') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode; - continue; - } - - if (optionName === 'tunnel-config') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelConfigPath = typeof value === 'string' ? value : null; - continue; - } - - if (optionName === 'tunnel-token') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken; - continue; - } - - if (optionName === 'tunnel-hostname') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname; - continue; - } - - if (optionName === 'tunnel') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelProvider = TUNNEL_PROVIDER_CLOUDFLARE; - options.tunnelMode = TUNNEL_MODE_MANAGED_LOCAL; - options.tunnelConfigPath = typeof value === 'string' ? value : null; - continue; - } - } - - return options; -} - -function killProcessOnPort(port) { - if (!port) return; - try { - // Kill any process listening on our port to clean up orphaned children. - const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); - const output = result.stdout || ''; - const myPid = process.pid; - for (const pidStr of output.split(/\s+/)) { - const pid = parseInt(pidStr.trim(), 10); - if (pid && pid !== myPid) { - try { - spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 }); - } catch { - // Ignore - } - } - } - } catch { - // Ignore - process may already be dead - } -} - -async function createManagedOpenCodeServerProcess({ - hostname, - port, - timeout, - cwd, - env, -}) { - let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; - let args = ['serve', '--hostname', hostname, '--port', String(port)]; - - if (process.platform === 'win32' && useWslForOpencode) { - const wslBinary = resolvedWslBinary || resolveWslExecutablePath(); - if (!wslBinary) { - throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); - } - - const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0 - ? resolvedWslOpencodePath.trim() - : 'opencode'; - const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; - - binary = wslBinary; - args = buildWslExecArgs([ - wslOpencode, - 'serve', - '--hostname', - serveHost, - '--port', - String(port), - ], resolvedWslDistro); - } - - // On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh). - // Detect if the resolved binary is a shim that wraps a Node/Bun script and - // resolve the actual target so we can spawn it with the correct interpreter. - if (process.platform === 'win32' && !useWslForOpencode) { - const interpreter = opencodeShimInterpreter(binary); - if (interpreter) { - // Binary itself has a node/bun shebang – spawn via that interpreter. - args.unshift(binary); - binary = interpreter; - } else { - // The wrapper might be a shell shim generated by npm. Try to find the - // real JS entry point next to it (e.g. node_modules/opencode-ai/bin/opencode). - try { - const shimContent = fs.readFileSync(binary, 'utf8'); - const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); - if (jsMatch) { - const candidate = path.resolve(path.dirname(binary), jsMatch[0]); - if (fs.existsSync(candidate)) { - const realInterp = opencodeShimInterpreter(candidate); - if (realInterp) { - args.unshift(candidate); - binary = realInterp; - } - } - } - } catch { - // ignore – fall through to default spawn - } - } - } - - const child = spawn(binary, args, { - cwd, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const url = await new Promise((resolve, reject) => { - let output = ''; - let done = false; - const finish = (handler, value) => { - if (done) return; - done = true; - clearTimeout(timer); - child.stdout?.off('data', onStdout); - child.stderr?.off('data', onStderr); - child.off('exit', onExit); - child.off('error', onError); - handler(value); - }; - - const onStdout = (chunk) => { - output += chunk.toString(); - const lines = output.split('\n'); - for (const line of lines) { - if (!line.startsWith('opencode server listening')) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match) { - finish(reject, new Error(`Failed to parse server url from output: ${line}`)); - return; - } - finish(resolve, match[1]); - return; - } - }; - - const onStderr = (chunk) => { - output += chunk.toString(); - }; - - const onExit = (code) => { - finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); - }; - - const onError = (error) => { - finish(reject, error); - }; - - const timer = setTimeout(() => { - finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); - }, timeout); - - child.stdout?.on('data', onStdout); - child.stderr?.on('data', onStderr); - child.on('exit', onExit); - child.on('error', onError); - }); - - return { - url, - close() { - try { - child.kill('SIGTERM'); - } catch { - // ignore - } - }, - }; -} - -async function resolveManagedOpenCodePort(requestedPort, hostname = '127.0.0.1') { - if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { - return requestedPort; - } - - return await new Promise((resolve, reject) => { - const server = net.createServer(); - const cleanup = () => { - server.removeAllListeners('error'); - server.removeAllListeners('listening'); - }; - - server.once('error', (error) => { - cleanup(); - reject(error); - }); - - server.once('listening', () => { - const address = server.address(); - const port = address && typeof address === 'object' ? address.port : 0; - server.close(() => { - cleanup(); - if (port > 0) { - resolve(port); - return; - } - reject(new Error('Failed to allocate OpenCode port')); - }); - }); - - server.listen(0, hostname); - }); -} - -async function startOpenCode() { - const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0; - const spawnPort = await resolveManagedOpenCodePort(desiredPort, ENV_CONFIGURED_OPENCODE_HOSTNAME); - console.log( - desiredPort > 0 - ? `Starting OpenCode on requested port ${desiredPort}...` - : `Starting OpenCode on allocated port ${spawnPort}...` - ); - - await applyOpencodeBinaryFromSettings(); - ensureOpencodeCliEnv(); - const openCodePassword = await ensureLocalOpenCodeServerPassword({ - rotateManaged: true, - }); - - try { - const serverInstance = await createManagedOpenCodeServerProcess({ - hostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, - port: spawnPort, - timeout: 30000, - cwd: openCodeWorkingDirectory, - env: { - ...process.env, - OPENCODE_SERVER_PASSWORD: openCodePassword, - }, - }); - - if (!serverInstance || !serverInstance.url) { - throw new Error('OpenCode server started but URL is missing'); - } - - const url = new URL(serverInstance.url); - const port = parseInt(url.port, 10); - const prefix = normalizeApiPrefix(url.pathname); - - if (await waitForReady(serverInstance.url, 10000)) { - setOpenCodePort(port); - setDetectedOpenCodeApiPrefix(prefix); // SDK URL typically includes the prefix if any - - isOpenCodeReady = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - - return serverInstance; - } else { - try { - serverInstance.close(); - } catch { - // ignore - } - throw new Error('Server started but health check failed (timeout)'); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - lastOpenCodeError = message; - openCodePort = null; - syncToHmrState(); - console.error(`Failed to start OpenCode: ${message}`); - throw error; - } -} - -async function restartOpenCode() { - if (isShuttingDown) return; - if (currentRestartPromise) { - await currentRestartPromise; - return; - } - - currentRestartPromise = (async () => { - isRestartingOpenCode = true; +const openCodeEnvState = {}; +Object.defineProperties(openCodeEnvState, { + cachedLoginShellEnvSnapshot: { get: () => cachedLoginShellEnvSnapshot, set: (value) => { cachedLoginShellEnvSnapshot = value; } }, + resolvedOpencodeBinary: { get: () => resolvedOpencodeBinary, set: (value) => { resolvedOpencodeBinary = value; } }, + resolvedOpencodeBinarySource: { get: () => resolvedOpencodeBinarySource, set: (value) => { resolvedOpencodeBinarySource = value; } }, + resolvedNodeBinary: { get: () => resolvedNodeBinary, set: (value) => { resolvedNodeBinary = value; } }, + resolvedBunBinary: { get: () => resolvedBunBinary, set: (value) => { resolvedBunBinary = value; } }, + resolvedGitBinary: { get: () => resolvedGitBinary, set: (value) => { resolvedGitBinary = value; } }, + useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, + resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, + resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, + resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, +}); + +const openCodeEnvRuntime = createOpenCodeEnvRuntime({ + state: openCodeEnvState, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + ENV_CONFIGURED_OPENCODE_WSL_DISTRO, +}); + +const applyLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.applyLoginShellEnvSnapshot(...args); +const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEnvSnapshot(...args); +const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args); +const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args); +const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args); +const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args); +const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args); +const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args); +const resolveWslExecutablePath = (...args) => openCodeEnvRuntime.resolveWslExecutablePath(...args); +const buildWslExecArgs = (...args) => openCodeEnvRuntime.buildWslExecArgs(...args); +const opencodeShimInterpreter = (...args) => openCodeEnvRuntime.opencodeShimInterpreter(...args); +const clearResolvedOpenCodeBinary = (...args) => openCodeEnvRuntime.clearResolvedOpenCodeBinary(...args); +const openCodeResolutionRuntime = createOpenCodeResolutionRuntime({ + path, + resolveOpencodeCliPath, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + opencodeShimInterpreter, + getResolvedState: () => ({ + resolvedOpencodeBinary, + resolvedOpencodeBinarySource, + useWslForOpencode, + resolvedWslBinary, + resolvedWslOpencodePath, + resolvedWslDistro, + resolvedNodeBinary, + resolvedBunBinary, + }), + setResolvedOpencodeBinarySource: (value) => { + resolvedOpencodeBinarySource = value; + }, +}); +const getOpenCodeResolutionSnapshot = (...args) => + openCodeResolutionRuntime.getOpenCodeResolutionSnapshot(...args); + +applyLoginShellEnvSnapshot(); + +notificationTemplateRuntime = createNotificationTemplateRuntime({ + readSettingsFromDisk, + persistSettings, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + resolveGitBinaryForSpawn, +}); + +const notificationTriggerRuntime = createNotificationTriggerRuntime({ + readSettingsFromDisk, + prepareNotificationLastMessage, + summarizeText, + resolveZenModel, + buildTemplateVariables, + extractLastMessageText, + fetchLastAssistantMessageText, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + emitDesktopNotification, + broadcastUiNotification, + sendPushToAllUiSessions, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, +}); + +const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); + +const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ + waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + parseSseDataPayload: (...args) => parseSseDataPayload(...args), + onPayload: (payload) => { + maybeCacheSessionInfoFromEvent(payload); + void maybeSendPushForTrigger(payload); + sessionRuntime.processOpenCodeSsePayload(payload); + }, +}); + + +const serverUtilsRuntime = createServerUtilsRuntime({ + fs, + os, + path, + process, + openCodeReadyGraceMs: OPEN_CODE_READY_GRACE_MS, + longRequestTimeoutMs: LONG_REQUEST_TIMEOUT_MS, + getRuntime: () => ({ + openCodePort, + openCodeNotReadySince, + isOpenCodeReady, + isRestartingOpenCode, + }), + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients: () => uiNotificationClients, + getOpenCodePort: () => openCodePort, + setOpenCodePortState: (value) => { + openCodePort = value; + }, + syncToHmrState, + markOpenCodeNotReady: () => { isOpenCodeReady = false; - openCodeNotReadySince = Date.now(); - console.log('Restarting OpenCode process...'); - - // For external OpenCode servers, re-probe instead of kill + respawn - if (isExternalOpenCode) { - console.log('Re-probing external OpenCode server...'); - const probePort = openCodePort || ENV_CONFIGURED_OPENCODE_PORT || 4096; - const probeOrigin = openCodeBaseUrl ?? ENV_CONFIGURED_OPENCODE_HOST?.origin; - const healthy = await probeExternalOpenCode(probePort, probeOrigin); - if (healthy) { - console.log(`External OpenCode server on port ${probePort} is healthy`); - setOpenCodePort(probePort); - isOpenCodeReady = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else { - lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`; - console.error(lastOpenCodeError); - throw new Error(lastOpenCodeError); - } - - if (expressApp) { - setupProxy(expressApp); - ensureOpenCodeApiPrefix(); - } - return; - } - - const portToKill = openCodePort; - - if (openCodeProcess) { - console.log('Stopping existing OpenCode process...'); - try { - openCodeProcess.close(); - } catch (error) { - console.warn('Error closing OpenCode process:', error); - } - openCodeProcess = null; - syncToHmrState(); - } - - killProcessOnPort(portToKill); - - // Brief delay to allow port release - await new Promise((resolve) => setTimeout(resolve, 250)); - - if (ENV_CONFIGURED_OPENCODE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); - setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); - } else { - openCodePort = null; - syncToHmrState(); - } - - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - if (openCodeApiDetectionTimer) { - clearTimeout(openCodeApiDetectionTimer); - openCodeApiDetectionTimer = null; - } - + }, + setOpenCodeNotReadySince: (value) => { + openCodeNotReadySince = value; + }, + clearLastOpenCodeError: () => { lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - - if (expressApp) { - setupProxy(expressApp); - // Ensure prefix is set correctly (SDK usually handles this, but just in case) - ensureOpenCodeApiPrefix(); + }, + getLoginShellPath: () => { + const snapshot = getLoginShellEnvSnapshot(); + if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) { + return null; } - })(); + return snapshot.PATH; + }, +}); - try { - await currentRestartPromise; - } catch (error) { - console.error(`Failed to restart OpenCode: ${error.message}`); - lastOpenCodeError = error.message; - if (!ENV_CONFIGURED_OPENCODE_PORT) { - openCodePort = null; - syncToHmrState(); - } - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - throw error; - } finally { - currentRestartPromise = null; - isRestartingOpenCode = false; - } -} +const setOpenCodePort = (...args) => serverUtilsRuntime.setOpenCodePort(...args); +const waitForOpenCodePort = (...args) => serverUtilsRuntime.waitForOpenCodePort(...args); +const buildAugmentedPath = (...args) => serverUtilsRuntime.buildAugmentedPath(...args); +const parseSseDataPayload = (...args) => serverUtilsRuntime.parseSseDataPayload(...args); +const staticRoutesRuntime = createStaticRoutesRuntime({ + fs, + path, + process, + __dirname, + express, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, +}); +const featureRoutesRuntime = createFeatureRoutesRuntime({ + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, +}); +const bootstrapRuntime = createBootstrapRuntime({ + createUiAuth, + registerServerStatusRoutes, + registerCommonRequestMiddleware, + registerAuthAndAccessRoutes, + registerTtsRoutes, + registerNotificationRoutes, + registerOpenChamberRoutes, + express, +}); +const tunnelWiringRuntime = createTunnelWiringRuntime({ + crypto, + URL, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActiveTunnelController: () => activeTunnelController, + setActiveTunnelController: (value) => { + activeTunnelController = value; + }, + getRuntimeManagedRemoteTunnelHostname: () => runtimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname: (value) => { + runtimeManagedRemoteTunnelHostname = value; + }, + getRuntimeManagedRemoteTunnelToken: () => runtimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken: (value) => { + runtimeManagedRemoteTunnelToken = value; + }, +}); +const startupPipelineRuntime = createStartupPipelineRuntime({ + createTerminalRuntime, + createServerStartupRuntime, +}); -async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } +const openCodeLifecycleState = {}; +Object.defineProperties(openCodeLifecycleState, { + openCodeProcess: { get: () => openCodeProcess, set: (value) => { openCodeProcess = value; } }, + openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, + openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, + openCodeWorkingDirectory: { get: () => openCodeWorkingDirectory, set: (value) => { openCodeWorkingDirectory = value; } }, + currentRestartPromise: { get: () => currentRestartPromise, set: (value) => { currentRestartPromise = value; } }, + isRestartingOpenCode: { get: () => isRestartingOpenCode, set: (value) => { isRestartingOpenCode = value; } }, + openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, + openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, + openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, + lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } }, + isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } }, + openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } }, + isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } }, + isShuttingDown: { get: () => isShuttingDown, set: (value) => { isShuttingDown = value; } }, + healthCheckInterval: { get: () => healthCheckInterval, set: (value) => { healthCheckInterval = value; } }, + expressApp: { get: () => expressApp, set: (value) => { expressApp = value; } }, + useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, + resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, + resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, + resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, +}); - const deadline = Date.now() + timeoutMs; - let lastError = null; +const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ + state: openCodeLifecycleState, + env: { + ENV_CONFIGURED_OPENCODE_PORT, + ENV_CONFIGURED_OPENCODE_HOST, + ENV_EFFECTIVE_PORT, + ENV_CONFIGURED_OPENCODE_HOSTNAME, + ENV_SKIP_OPENCODE_START, + }, + syncToHmrState, + syncFromHmrState, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + waitForReady, + normalizeApiPrefix, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + ensureLocalOpenCodeServerPassword, + buildWslExecArgs, + resolveWslExecutablePath, + opencodeShimInterpreter, + setOpenCodePort, + setDetectedOpenCodeApiPrefix, + setupProxy: (...args) => setupProxy(...args), + ensureOpenCodeApiPrefix, + clearResolvedOpenCodeBinary, +}); - while (Date.now() < deadline) { - try { - const [configResult, agentResult] = await Promise.all([ - fetch(buildOpenCodeUrl('/config', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }).catch((error) => error), - fetch(buildOpenCodeUrl('/agent', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }).catch((error) => error) - ]); - - if (configResult instanceof Error) { - lastError = configResult; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - if (!configResult.ok) { - lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - await configResult.json().catch(() => null); - - if (agentResult instanceof Error) { - lastError = agentResult; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - if (!agentResult.ok) { - lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - await agentResult.json().catch(() => []); - - isOpenCodeReady = true; - lastOpenCodeError = null; - return; - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - if (lastError) { - lastOpenCodeError = lastError.message || String(lastError); - throw lastError; - } - - const timeoutError = new Error('Timed out waiting for OpenCode to become ready'); - lastOpenCodeError = timeoutError.message; - throw timeoutError; -} - -async function waitForAgentPresence(agentName, timeoutMs = 15000, intervalMs = 300) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - try { - const response = await fetch(buildOpenCodeUrl('/agent'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (response.ok) { - const agents = await response.json(); - if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) { - return; - } - } - } catch (error) { - - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error(`Agent "${agentName}" not available after OpenCode restart`); -} - -async function fetchAgentsSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/agent'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } +const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); +const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args); +const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args); +const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args); +const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL); +const bootstrapOpenCodeAtStartup = async (...args) => { + await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args); + scheduleOpenCodeApiDetection(); + startHealthMonitoring(); + void openCodeWatcherRuntime.start().catch((error) => { + console.warn(`Global event watcher startup failed: ${error?.message || error}`); }); - - if (!response.ok) { - throw new Error(`Failed to fetch agents snapshot (status ${response.status})`); - } - - const agents = await response.json().catch(() => null); - if (!Array.isArray(agents)) { - throw new Error('Invalid agents payload from OpenCode'); - } - return agents; -} - -async function fetchProvidersSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/provider'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch providers snapshot (status ${response.status})`); - } - - const providers = await response.json().catch(() => null); - if (!Array.isArray(providers)) { - throw new Error('Invalid providers payload from OpenCode'); - } - return providers; -} - -async function fetchModelsSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/model'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch models snapshot (status ${response.status})`); - } - - const models = await response.json().catch(() => null); - if (!Array.isArray(models)) { - throw new Error('Invalid models payload from OpenCode'); - } - return models; -} - -async function refreshOpenCodeAfterConfigChange(reason, options = {}) { - const { agentName } = options; - - console.log(`Refreshing OpenCode after ${reason}`); - - // Settings might include a new opencodeBinary; drop cache before restart. - resolvedOpencodeBinary = null; - await applyOpencodeBinaryFromSettings(); - - await restartOpenCode(); - - try { - await waitForOpenCodeReady(); - isOpenCodeReady = true; - openCodeNotReadySince = 0; - - if (agentName) { - await waitForAgentPresence(agentName); - } - - isOpenCodeReady = true; - openCodeNotReadySince = 0; - } catch (error) { - - isOpenCodeReady = false; - openCodeNotReadySince = Date.now(); - console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); - throw error; - } -} - -async function bootstrapOpenCodeAtStartup() { - try { - syncFromHmrState(); - if (await isOpenCodeProcessHealthy()) { - console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); - } else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Using external OpenCode server at ${label} (skip-start mode)`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Auto-detected existing OpenCode server at ${label}`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { - console.log('Auto-detected existing OpenCode server on default port 4096'); - setOpenCodePort(4096); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else { - if (ENV_EFFECTIVE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`); - setOpenCodePort(ENV_EFFECTIVE_PORT); - } else { - openCodePort = null; - syncToHmrState(); - } - - lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - } - await waitForOpenCodePort(); - try { - await waitForOpenCodeReady(); - } catch (error) { - console.error(`OpenCode readiness check failed: ${error.message}`); - scheduleOpenCodeApiDetection(); - } - scheduleOpenCodeApiDetection(); - startHealthMonitoring(); - void startGlobalEventWatcher().catch((error) => { - console.warn(`Global event watcher startup failed: ${error?.message || error}`); - }); - } catch (error) { - console.error(`Failed to start OpenCode: ${error.message}`); - console.log('Continuing without OpenCode integration...'); - lastOpenCodeError = error.message; - scheduleOpenCodeApiDetection(); - } -} - -function setupProxy(app) { - if (app.get('opencodeProxyConfigured')) { - return; - } - - if (openCodePort) { - console.log(`Setting up proxy to OpenCode on port ${openCodePort}`); - } else { - console.log('Setting up OpenCode API gate (OpenCode not started yet)'); - } - app.set('opencodeProxyConfigured', true); - - const stripApiPrefix = (rawUrl) => { - if (typeof rawUrl !== 'string' || !rawUrl) { - return '/'; - } - if (rawUrl === '/api') { - return '/'; - } - if (rawUrl.startsWith('/api/')) { - return rawUrl.slice(4); - } - return rawUrl; - }; - - // Keep route matching stable; only rewrite the proxied upstream path. - const rewriteWindowsDirectoryParam = (upstreamPath) => { - if (process.platform !== 'win32') { - return upstreamPath; - } - try { - const parsed = new URL(upstreamPath, 'http://openchamber.local'); - const pathname = parsed.pathname || '/'; - if (pathname === '/session' || pathname.startsWith('/session/')) { - return upstreamPath; - } - const directory = parsed.searchParams.get('directory'); - if (!directory || !directory.includes('/')) { - return upstreamPath; - } - const fixed = directory.replace(/\//g, '\\'); - parsed.searchParams.set('directory', fixed); - const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`; - if (rewritten !== upstreamPath) { - console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`); - console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`); - } - return rewritten; - } catch { - return upstreamPath; - } - }; - - const getUpstreamPathForRequest = (req) => { - const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl) - ? req.originalUrl - : (typeof req.url === 'string' ? req.url : '/'); - return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl)); - }; - - app.use('/api', (req, res, next) => { - if ( - req.path.startsWith('/themes/custom') || - req.path.startsWith('/push') || - req.path.startsWith('/config/agents') || - req.path.startsWith('/config/opencode-resolution') || - req.path.startsWith('/config/settings') || - req.path.startsWith('/config/skills') || - req.path === '/config/reload' || - req.path === '/health' - ) { - return next(); - } - - const waitElapsed = openCodeNotReadySince === 0 ? 0 : Date.now() - openCodeNotReadySince; - const stillWaiting = - (!isOpenCodeReady && (openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || - isRestartingOpenCode || - !openCodePort; - - if (stillWaiting) { - return res.status(503).json({ - error: 'OpenCode is restarting', - restarting: true, - }); - } - - next(); - }); - - const isSseApiPath = (path) => path === '/event' || path === '/global/event'; - - const forwardSseRequest = async (req, res) => { - const startedAt = Date.now(); - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const authHeaders = getOpenCodeAuthHeaders(); - - const requestHeaders = { - ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }), - 'cache-control': 'no-cache', - connection: 'keep-alive', - ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), - }; - - const controller = new AbortController(); - let connectTimer = null; - let idleTimer = null; - let heartbeatTimer = null; - let endedBy = 'upstream-end'; - - const cleanup = () => { - if (connectTimer) { - clearTimeout(connectTimer); - connectTimer = null; - } - if (idleTimer) { - clearTimeout(idleTimer); - idleTimer = null; - } - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - req.off('close', onClientClose); - }; - - const resetIdleTimeout = () => { - if (idleTimer) { - clearTimeout(idleTimer); - } - idleTimer = setTimeout(() => { - endedBy = 'idle-timeout'; - controller.abort(); - }, 5 * 60 * 1000); - }; - - const onClientClose = () => { - endedBy = 'client-disconnect'; - controller.abort(); - }; - - req.on('close', onClientClose); - - try { - connectTimer = setTimeout(() => { - endedBy = 'connect-timeout'; - controller.abort(); - }, 10 * 1000); - - const upstreamResponse = await fetch(targetUrl, { - method: 'GET', - headers: requestHeaders, - signal: controller.signal, - }); - - if (connectTimer) { - clearTimeout(connectTimer); - connectTimer = null; - } - - if (!upstreamResponse.ok || !upstreamResponse.body) { - const body = await upstreamResponse.text().catch(() => ''); - cleanup(); - if (!res.headersSent) { - if (upstreamResponse.headers.has('content-type')) { - res.setHeader('content-type', upstreamResponse.headers.get('content-type')); - } - res.status(upstreamResponse.status).send(body); - } - return; - } - - const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream'; - res.status(upstreamResponse.status); - res.setHeader('content-type', upstreamContentType); - res.setHeader('cache-control', 'no-cache'); - res.setHeader('connection', 'keep-alive'); - res.setHeader('x-accel-buffering', 'no'); - res.setHeader('x-content-type-options', 'nosniff'); - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - resetIdleTimeout(); - heartbeatTimer = setInterval(() => { - if (res.writableEnded || controller.signal.aborted) { - return; - } - try { - res.write(': ping\n\n'); - resetIdleTimeout(); - } catch { - } - }, 30 * 1000); - - const reader = upstreamResponse.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy; - break; - } - if (controller.signal.aborted) { - break; - } - if (value && value.length > 0) { - res.write(Buffer.from(value)); - resetIdleTimeout(); - } - } - } finally { - try { - reader.releaseLock(); - } catch { - } - } - - cleanup(); - if (!res.writableEnded) { - res.end(); - } - console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`); - } catch (error) { - cleanup(); - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - if (!res.headersSent) { - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed', - }); - } else if (!res.writableEnded) { - res.end(); - } - console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error); - } - }; - - app.get('/api/event', forwardSseRequest); - app.get('/api/global/event', forwardSseRequest); - - app.use('/api', (_req, _res, next) => { - ensureOpenCodeApiPrefix(); - next(); - }); - - app.use('/api', (req, res, next) => { - if ( - req.path.startsWith('/themes/custom') || - req.path.startsWith('/config/agents') || - req.path.startsWith('/config/opencode-resolution') || - req.path.startsWith('/config/settings') || - req.path.startsWith('/config/skills') || - req.path === '/health' - ) { - return next(); - } - console.log(`API → OpenCode: ${req.method} ${req.path}`); - next(); - }); - - - const hopByHopRequestHeaders = new Set([ - 'host', - 'connection', - 'content-length', - 'transfer-encoding', - 'keep-alive', - 'te', - 'trailer', - 'upgrade', - ]); - - const hopByHopResponseHeaders = new Set([ - 'connection', - 'content-length', - 'transfer-encoding', - 'keep-alive', - 'te', - 'trailer', - 'upgrade', - 'www-authenticate', - ]); - - const collectForwardHeaders = (req) => { - const authHeaders = getOpenCodeAuthHeaders(); - const headers = {}; - - for (const [key, value] of Object.entries(req.headers || {})) { - if (!value) continue; - const lowerKey = key.toLowerCase(); - if (hopByHopRequestHeaders.has(lowerKey)) continue; - headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value); - } - - if (authHeaders.Authorization) { - headers.Authorization = authHeaders.Authorization; - } - - return headers; - }; - - const collectRequestBodyBuffer = async (req) => { - if (Buffer.isBuffer(req.body)) { - return req.body; - } - - if (typeof req.body === 'string') { - return Buffer.from(req.body); - } - - if (req.body && typeof req.body === 'object') { - return Buffer.from(JSON.stringify(req.body)); - } - - if (req.readableEnded) { - return Buffer.alloc(0); - } - - return await new Promise((resolve, reject) => { - const chunks = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); - }; - - const forwardGenericApiRequest = async (req, res) => { - try { - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const headers = collectForwardHeaders(req); - const method = String(req.method || 'GET').toUpperCase(); - const hasBody = method !== 'GET' && method !== 'HEAD'; - const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null; - - const upstreamResponse = await fetch(targetUrl, { - method, - headers, - body: hasBody ? bodyBuffer : undefined, - signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS), - }); - - for (const [key, value] of upstreamResponse.headers.entries()) { - const lowerKey = key.toLowerCase(); - if (hopByHopResponseHeaders.has(lowerKey)) { - continue; - } - res.setHeader(key, value); - } - - const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); - res.status(upstreamResponse.status).send(upstreamBody); - } catch (error) { - if (!res.headersSent) { - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable', - }); - } - } - }; - - // Dedicated forwarder for large session message payloads. - // This avoids edge-cases in generic proxy streaming for multi-file attachments. - app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { - try { - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const authHeaders = getOpenCodeAuthHeaders(); - - const headers = { - ...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }), - ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}), - ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), - }; - - const bodyBuffer = Buffer.isBuffer(req.body) - ? req.body - : Buffer.from(typeof req.body === 'string' ? req.body : ''); - - const upstreamResponse = await fetch(targetUrl, { - method: 'POST', - headers, - body: bodyBuffer, - signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS), - }); - - const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); - - if (upstreamResponse.headers.has('content-type')) { - res.setHeader('content-type', upstreamResponse.headers.get('content-type')); - } - - res.status(upstreamResponse.status).send(upstreamBody); - } catch (error) { - if (!res.headersSent) { - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed', - }); - } - } - }); - - app.use('/api', async (req, res, next) => { - if (isSseApiPath(req.path)) { - return next(); - } - - if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) { - return next(); - } - - // Windows: Merge sessions from all project directories on bare GET /session - if (process.platform === 'win32' && req.method === 'GET' && req.path === '/session') { - const rawUrl = req.originalUrl || req.url || ''; - if (!rawUrl.includes('directory=')) { - try { - const authHeaders = getOpenCodeAuthHeaders(); - const fetchOpts = { - method: 'GET', - headers: { Accept: 'application/json', ...authHeaders }, - signal: AbortSignal.timeout(10000), - }; - const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); - const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : []; - const globalSessions = Array.isArray(globalPayload) ? globalPayload : []; - - const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); - let projectDirs = []; - try { - const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); - const settings = JSON.parse(settingsRaw); - projectDirs = (settings.projects || []) - .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) - .filter(Boolean); - } catch {} - - const seen = new Set( - globalSessions - .map((session) => (session && typeof session.id === 'string' ? session.id : null)) - .filter((id) => typeof id === 'string') - ); - const extraSessions = []; - for (const dir of projectDirs) { - const candidates = Array.from(new Set([ - dir, - dir.replace(/\\/g, '/'), - dir.replace(/\//g, '\\'), - ])); - for (const candidateDir of candidates) { - const encoded = encodeURIComponent(candidateDir); - try { - const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); - if (dirRes.ok) { - const dirPayload = await dirRes.json().catch(() => []); - const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; - for (const session of dirSessions) { - const id = session && typeof session.id === 'string' ? session.id : null; - if (id && !seen.has(id)) { - seen.add(id); - extraSessions.push(session); - } - } - } - } catch {} - } - } - - const merged = [...globalSessions, ...extraSessions]; - merged.sort((a, b) => { - const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; - const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; - return bTime - aTime; - }); - console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); - return res.json(merged); - } catch (error) { - console.log(`[SessionMerge] Error: ${error.message}, falling through`); - } - } - } - - return forwardGenericApiRequest(req, res); - }); -} - -function startHealthMonitoring() { - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - } - - healthCheckInterval = setInterval(async () => { - if (!openCodeProcess || isShuttingDown || isRestartingOpenCode) return; - - try { - const healthy = await isOpenCodeProcessHealthy(); - if (!healthy) { - console.log('OpenCode process not running, restarting...'); - await restartOpenCode(); - } - } catch (error) { - console.error(`Health check error: ${error.message}`); - } - }, HEALTH_CHECK_INTERVAL); -} - -async function gracefulShutdown(options = {}) { - if (isShuttingDown) return; - - isShuttingDown = true; - syncToHmrState(); - console.log('Starting graceful shutdown...'); - const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown; - - stopGlobalEventWatcher(); - - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - } - - if (terminalInputWsServer) { - try { - for (const client of terminalInputWsServer.clients) { - try { - client.terminate(); - } catch { - } - } - - await new Promise((resolve) => { - terminalInputWsServer.close(() => resolve()); - }); - } catch { - } finally { - terminalInputWsServer = null; - } - } - - // Only stop OpenCode if we started it ourselves (not when using external server) - if (!ENV_SKIP_OPENCODE_START && !isExternalOpenCode) { - const portToKill = openCodePort; - - if (openCodeProcess) { - console.log('Stopping OpenCode process...'); - try { - openCodeProcess.close(); - } catch (error) { - console.warn('Error closing OpenCode process:', error); - } - openCodeProcess = null; - } - - killProcessOnPort(portToKill); - } else { - console.log('Skipping OpenCode shutdown (external server)'); - } - - if (server) { - await Promise.race([ - new Promise((resolve) => { - server.close(() => { - console.log('HTTP server closed'); - resolve(); - }); - }), - new Promise((resolve) => { - setTimeout(() => { - console.warn('Server close timeout reached, forcing shutdown'); - resolve(); - }, SHUTDOWN_TIMEOUT); - }) - ]); - } - - if (uiAuthController) { - uiAuthController.dispose(); - uiAuthController = null; - } - - if (activeTunnelController) { - console.log('Stopping active tunnel...'); - activeTunnelController.stop(); - activeTunnelController = null; - tunnelAuthController.clearActiveTunnel(); - } - - console.log('Graceful shutdown complete'); - if (exitProcess) { - process.exit(0); - } -} +}; +const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); + +const fetchAgentsSnapshot = (...args) => serverUtilsRuntime.fetchAgentsSnapshot(...args); +const fetchProvidersSnapshot = (...args) => serverUtilsRuntime.fetchProvidersSnapshot(...args); +const fetchModelsSnapshot = (...args) => serverUtilsRuntime.fetchModelsSnapshot(...args); +const setupProxy = (...args) => serverUtilsRuntime.setupProxy(...args); +const gracefulShutdownRuntime = createGracefulShutdownRuntime({ + process, + shutdownTimeoutMs: SHUTDOWN_TIMEOUT, + getExitOnShutdown: () => exitOnShutdown, + getIsShuttingDown: () => isShuttingDown, + setIsShuttingDown: (value) => { + isShuttingDown = value; + }, + syncToHmrState, + openCodeWatcherRuntime, + sessionRuntime, + getHealthCheckInterval: () => healthCheckInterval, + clearHealthCheckInterval: (value) => clearInterval(value), + getTerminalRuntime: () => terminalRuntime, + setTerminalRuntime: (value) => { + terminalRuntime = value; + }, + shouldSkipOpenCodeStop: () => ENV_SKIP_OPENCODE_START || isExternalOpenCode, + getOpenCodePort: () => openCodePort, + getOpenCodeProcess: () => openCodeProcess, + setOpenCodeProcess: (value) => { + openCodeProcess = value; + }, + killProcessOnPort, + getServer: () => server, + getUiAuthController: () => uiAuthController, + setUiAuthController: (value) => { + uiAuthController = value; + }, + getActiveTunnelController: () => activeTunnelController, + setActiveTunnelController: (value) => { + activeTunnelController = value; + }, + tunnelAuthController, +}); + +const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(...args); async function main(options = {}) { const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT; @@ -7151,33 +814,7 @@ async function main(options = {}) { console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); - // Check macOS Say TTS availability once at startup - let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' }; - if (process.platform === 'darwin') { - try { - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - const { stdout } = await execAsync('say -v "?"'); - const voices = stdout.split('\n') - .filter(line => line.trim()) - .map(line => { - const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/); - if (match) { - return { name: match[1].trim(), locale: match[2] }; - } - return null; - }) - .filter(Boolean); - sayTTSCapability = { available: true, voices }; - console.log(`macOS Say TTS available with ${voices.length} voices`); - } catch (error) { - sayTTSCapability = { available: false, voices: [], reason: 'say command not available' }; - console.log('macOS Say TTS not available:', error.message); - } - } else { - sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; - } + const sayTTSCapability = await detectSayTtsCapability(process); // Startup model validation is best-effort and runs in background. void validateZenModelAtStartup(); @@ -7188,11 +825,15 @@ async function main(options = {}) { expressApp = app; server = http.createServer(app); - app.get('/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - openCodePort: openCodePort, + const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null; + const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { + process, + openchamberVersion: OPENCHAMBER_VERSION, + runtimeName: process.env.OPENCHAMBER_RUNTIME || 'web', + serverStartedAt, + gracefulShutdown, + getHealthSnapshot: () => ({ + openCodePort, openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), openCodeSecureConnection: isOpenCodeConnectionSecure(), openCodeAuthSource: openCodeAuthSource || null, @@ -7209,7175 +850,117 @@ async function main(options = {}) { opencodeWslDistro: resolvedWslDistro || null, nodeBinaryResolved: resolvedNodeBinary || null, bunBinaryResolved: resolvedBunBinary || null, - }); + }), + uiPassword, + tunnelAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + resolveZenModel, + sayTTSCapability, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + sessionRuntime, + setPushInitialized, + fs, + os, + path, + server, + __dirname, + openchamberDataDir: OPENCHAMBER_DATA_DIR, + modelsDevApiUrl: MODELS_DEV_API_URL, + modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL, + fetchFreeZenModels, + getCachedZenModels, + }); + uiAuthController = bootstrapResult.uiAuthController; + + const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); + const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; + + await featureRoutesRuntime.registerRoutes(app, { + crypto, + fs, + os, + path, + fsPromises, + spawn, + resolveGitBinaryForSpawn, + createFsSearchRuntime: createFsSearchRuntimeFactory, + openchamberDataDir: OPENCHAMBER_DATA_DIR, + openchamberUserConfigRoot: OPENCHAMBER_USER_CONFIG_ROOT, + normalizeDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + validateDirectoryPath, + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort: () => openCodePort, + buildAugmentedPath, }); - app.post('/api/system/shutdown', (req, res) => { - res.json({ ok: true }); - gracefulShutdown({ exitProcess: false }).catch((error) => { - console.error('Shutdown request failed:', error?.message || error); - }); - }); - - app.get('/api/system/info', (req, res) => { - res.json({ - openchamberVersion: OPENCHAMBER_VERSION, - runtime: process.env.OPENCHAMBER_RUNTIME || 'web', - pid: process.pid, - startedAt: serverStartedAt, - }); - }); - - app.use((req, res, next) => { - if ( - req.path.startsWith('/api/config/agents') || - req.path.startsWith('/api/config/commands') || - req.path.startsWith('/api/config/mcp') || - req.path.startsWith('/api/config/settings') || - req.path.startsWith('/api/config/skills') || - req.path.startsWith('/api/projects') || - req.path.startsWith('/api/fs') || - req.path.startsWith('/api/git') || - req.path.startsWith('/api/prompts') || - req.path.startsWith('/api/terminal') || - req.path.startsWith('/api/opencode') || - req.path.startsWith('/api/push') || - req.path.startsWith('/api/voice') || - req.path.startsWith('/api/tts') || - req.path.startsWith('/api/openchamber/tunnel') - ) { - - express.json({ limit: '50mb' })(req, res, next); - } else if (req.path.startsWith('/api')) { - - next(); - } else { - - express.json({ limit: '50mb' })(req, res, next); - } - }); - app.use(express.urlencoded({ extended: true, limit: '50mb' })); - - app.use((req, res, next) => { - console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); - next(); - }); - - const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null; - uiAuthController = createUiAuth({ password: uiPassword }); - if (uiAuthController.enabled) { - console.log('UI password protection enabled for browser sessions'); - } - - app.get('/auth/session', async (req, res) => { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req); - if (tunnelSession) { - return res.json({ authenticated: true, scope: 'tunnel' }); - } - tunnelAuthController.clearTunnelSessionCookie(req, res); - return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true }); - } - - try { - await uiAuthController.handleSessionStatus(req, res); - } catch (err) { - res.status(500).json({ error: 'Internal server error' }); - } - }); - app.post('/auth/session', (req, res) => { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true }); - } - return uiAuthController.handleSessionCreate(req, res); - }); - - app.get('/connect', async (req, res) => { - try { - const token = typeof req.query?.t === 'string' ? req.query.t : ''; - const settings = await readSettingsFromDiskMigrated(); - const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - - const exchange = tunnelAuthController.exchangeBootstrapToken({ - req, - res, - token, - sessionTtlMs: tunnelSessionTtlMs, - }); - - res.setHeader('Cache-Control', 'no-store'); - - if (!exchange.ok) { - if (exchange.reason === 'rate-limited') { - res.setHeader('Retry-After', String(exchange.retryAfter || 60)); - return res.status(429).type('text/plain').send('Too many attempts. Please try again later.'); - } - return res.status(401).type('text/plain').send('Connection link is invalid or expired.'); - } - - return res.redirect(302, '/'); - } catch (error) { - return res.status(500).type('text/plain').send('Failed to process connect request.'); - } - }); - - app.use('/api', async (req, res, next) => { - try { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - return tunnelAuthController.requireTunnelSession(req, res, next); - } - await uiAuthController.requireAuth(req, res, next); - } catch (err) { - next(err); - } - }); - - const parsePushSubscribeBody = (body) => { - if (!body || typeof body !== 'object') return null; - const endpoint = body.endpoint; - const keys = body.keys; - const p256dh = keys?.p256dh; - const auth = keys?.auth; - - if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; - if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null; - if (typeof auth !== 'string' || auth.trim().length === 0) return null; - - return { - endpoint: endpoint.trim(), - keys: { p256dh: p256dh.trim(), auth: auth.trim() }, - }; - }; - - const parsePushUnsubscribeBody = (body) => { - if (!body || typeof body !== 'object') return null; - const endpoint = body.endpoint; - if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; - return { endpoint: endpoint.trim() }; - }; - - app.get('/api/push/vapid-public-key', async (req, res) => { - try { - await ensurePushInitialized(); - const keys = await getOrCreateVapidKeys(); - res.json({ publicKey: keys.publicKey }); - } catch (error) { - console.warn('[Push] Failed to load VAPID key:', error); - res.status(500).json({ error: 'Failed to load push key' }); - } - }); - - app.post('/api/push/subscribe', async (req, res) => { - await ensurePushInitialized(); - - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const parsed = parsePushSubscribeBody(req.body); - if (!parsed) { - return res.status(400).json({ error: 'Invalid body' }); - } - - const { endpoint, keys } = parsed; - - const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : ''; - if (origin.startsWith('http://') || origin.startsWith('https://')) { - try { - const settings = await readSettingsFromDiskMigrated(); - if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) { - await writeSettingsToDisk({ - ...settings, - publicOrigin: origin, - }); - // allow next sends to pick it up - pushInitialized = false; - } - } catch { - // ignore - } - } - - await addOrUpdatePushSubscription( - uiToken, - { - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - }, - req.headers['user-agent'] - ); - - res.json({ ok: true }); - }); - - - app.delete('/api/push/subscribe', async (req, res) => { - await ensurePushInitialized(); - - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const parsed = parsePushUnsubscribeBody(req.body); - if (!parsed) { - return res.status(400).json({ error: 'Invalid body' }); - } - - await removePushSubscription(uiToken, parsed.endpoint); - res.json({ ok: true }); - }); - - app.post('/api/push/visibility', async (req, res) => { - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; - updateUiVisibility(uiToken, visible === true); - res.json({ ok: true }); - }); - - app.get('/api/push/visibility', (req, res) => { - const uiToken = getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - res.json({ - ok: true, - visible: isUiVisible(uiToken), - }); - }); - - // Session activity status endpoint - returns tracked activity phases for all sessions - // Used by UI on visibility restore to get accurate status without waiting for SSE - app.get('/api/session-activity', (_req, res) => { - res.json(getSessionActivitySnapshot()); - }); - - // Voice token endpoint - returns OpenAI TTS availability status - app.post('/api/voice/token', async (req, res) => { - console.log('[Voice] Token request received:', { - contentType: req.headers['content-type'] || null, - }); - try { - const openaiApiKey = process.env.OPENAI_API_KEY; - console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); - - if (!openaiApiKey) { - return res.status(503).json({ - allowed: false, - error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.' - }); - } - - // Return success - OpenAI TTS is available - res.json({ - allowed: true, - provider: 'openai', - message: 'OpenAI TTS is available' - }); - } catch (error) { - console.error('[Voice] Token generation error:', error); - res.status(500).json({ - allowed: false, - error: 'Voice service error' - }); - } - }); - - // Server-side TTS endpoint - streams audio from OpenAI TTS API - app.post('/api/tts/speak', async (req, res) => { - try { - const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {}; - - console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey }); - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - // Dynamically import the TTS service (ESM) - const { ttsService } = await import('./lib/tts/index.js'); - - // Check availability - either server-configured or client-provided API key - const hasServerKey = ttsService.isAvailable(); - const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; - - if (!hasServerKey && !hasClientKey) { - return res.status(503).json({ - error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.' - }); - } - - let textToSpeak = text.trim(); - - // Optionally summarize long text before speaking using zen API - if (summarize && textToSpeak.length > threshold) { - try { - const { summarizeText } = await import('./lib/tts/index.js'); - const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); - - if (result.summarized && result.summary) { - textToSpeak = result.summary; - } - } catch (summarizeError) { - console.error('[TTS/speak] Summarization failed:', summarizeError); - // Continue with original text if summarization fails - } - } - - const result = await ttsService.generateSpeechStream({ - text: textToSpeak, - voice, - model, - speed, - instructions, - apiKey: hasClientKey ? apiKey.trim() : undefined - }); - - // Set headers for audio streaming - // Note: Don't set Transfer-Encoding manually - Express handles it automatically - res.setHeader('Content-Type', result.contentType); - res.setHeader('Cache-Control', 'no-cache'); - - // Collect the full audio buffer and send it - // This avoids chunked encoding issues with proxies - const reader = result.stream.getReader(); - const chunks = []; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(Buffer.from(value)); - } - const audioBuffer = Buffer.concat(chunks); - res.setHeader('Content-Length', audioBuffer.length); - res.send(audioBuffer); - } catch (streamError) { - console.error('[TTS] Stream error:', streamError); - if (!res.headersSent) { - res.status(500).json({ error: 'Stream error' }); - } else { - res.end(); - } - } - } catch (error) { - console.error('[TTS] Error:', error); - if (!res.headersSent) { - res.status(500).json({ - error: error instanceof Error ? error.message : 'TTS generation failed' - }); - } - } - }); - - // Import summarization service - const { summarizeText, sanitizeForTTS } = await import('./lib/tts/index.js'); - - app.post('/api/tts/summarize', async (req, res) => { - try { - const { text, threshold = 200, maxLength = 500 } = req.body || {}; - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); - - return res.json(result); - } catch (error) { - console.error('[Summarize] Error:', error); - const sanitized = sanitizeForTTS(req.body?.text || ''); - return res.json({ summary: sanitized, summarized: false, reason: error.message }); - } - }); - - - // TTS status endpoint - app.get('/api/tts/status', async (_req, res) => { - try { - const { ttsService } = await import('./lib/tts/index.js'); - res.json({ - available: ttsService.isAvailable(), - voices: [ - 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', - 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar' - ] - }); - } catch (error) { - res.status(500).json({ error: 'Failed to check TTS status' }); - } - }); - - // macOS 'say' command TTS status endpoint - returns cached capability from startup - app.get('/api/tts/say/status', (_req, res) => { - res.json(sayTTSCapability); - }); - - // macOS 'say' command TTS speak endpoint - app.post('/api/tts/say/speak', async (req, res) => { - try { - const { text, voice = 'Samantha', rate = 200 } = req.body || {}; - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - // Check if we're on macOS - if (process.platform !== 'darwin') { - return res.status(503).json({ error: 'macOS say command not available on this platform' }); - } - - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const fs = await import('fs'); - const os = await import('os'); - const path = await import('path'); - const execAsync = promisify(exec); - - // Create temp file for audio output (use m4a for browser compatibility) - const tempDir = os.tmpdir(); - const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`); - - // Escape text for shell - escape both single quotes and double quotes - const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"'); - - // Generate audio file using 'say' command - // -o outputs to file, -r sets rate (words per minute) - // --data-format=aac outputs as m4a which browsers can decode - const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`; - console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate }); - - await execAsync(cmd); - - // Read the generated audio file - const audioBuffer = await fs.promises.readFile(tempFile); - - // Clean up temp file - fs.promises.unlink(tempFile).catch(() => {}); - - // Send audio response - res.setHeader('Content-Type', 'audio/mp4'); - res.setHeader('Content-Length', audioBuffer.length); - res.send(audioBuffer); - - } catch (error) { - console.error('[TTS-Say] Error:', error); - res.status(500).json({ - error: error instanceof Error ? error.message : 'Say command failed' - }); - } - }); - - // New authoritative session status endpoints - // Server maintains the source of truth, clients only query - - // GET /api/sessions/snapshot - Combined status + attention snapshot - app.get('/api/sessions/snapshot', (_req, res) => { - res.json({ - statusSessions: getSessionStateSnapshot(), - attentionSessions: getSessionAttentionSnapshot(), - serverTime: Date.now() - }); - }); - - // GET /api/sessions/status - Get status for all sessions - app.get('/api/sessions/status', (_req, res) => { - const snapshot = getSessionStateSnapshot(); - res.json({ - sessions: snapshot, - serverTime: Date.now() - }); - }); - - // GET /api/sessions/:id/status - Get status for a specific session - app.get('/api/sessions/:id/status', (req, res) => { - const sessionId = req.params.id; - const state = getSessionState(sessionId); - - if (!state) { - return res.status(404).json({ - error: 'Session not found or no state available', - sessionId - }); - } - - res.json({ - sessionId, - ...state - }); - }); - - // Session attention tracking endpoints - // GET /api/sessions/attention - Get attention state for all sessions - app.get('/api/sessions/attention', (_req, res) => { - const snapshot = getSessionAttentionSnapshot(); - res.json({ - sessions: snapshot, - serverTime: Date.now() - }); - }); - - // GET /api/sessions/:id/attention - Get attention state for a specific session - app.get('/api/sessions/:id/attention', (req, res) => { - const sessionId = req.params.id; - const state = getSessionAttentionState(sessionId); - - if (!state) { - return res.status(404).json({ - error: 'Session not found or no attention state available', - sessionId - }); - } - - res.json({ - sessionId, - ...state - }); - }); - - // POST /api/sessions/:id/view - Client reports viewing this session - app.post('/api/sessions/:id/view', (req, res) => { - const sessionId = req.params.id; - const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; - - markSessionViewed(sessionId, clientId); - - res.json({ - success: true, - sessionId, - viewed: true - }); - }); - - // POST /api/sessions/:id/unview - Client reports leaving this session - app.post('/api/sessions/:id/unview', (req, res) => { - const sessionId = req.params.id; - const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; - - markSessionUnviewed(sessionId, clientId); - - res.json({ - success: true, - sessionId, - viewed: false - }); - }); - - // POST /api/sessions/:id/message-sent - User sent a message in this session - app.post('/api/sessions/:id/message-sent', (req, res) => { - const sessionId = req.params.id; - - markUserMessageSent(sessionId); - - res.json({ - success: true, - sessionId, - messageSent: true - }); - }); - - app.get('/api/openchamber/update-check', async (req, res) => { - try { - const { checkForUpdates } = await import('./lib/package-manager.js'); - const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); - const parseReportUsage = (value) => { - if (typeof value !== 'string') return true; - const normalized = value.trim().toLowerCase(); - if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; - return true; - }; - const inferDeviceClass = (ua) => { - const value = (ua || '').toLowerCase(); - if (!value) return 'unknown'; - if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; - if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; - return 'desktop'; - }; - const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; - - const updateInfo = await checkForUpdates({ - appType: parseString(req.query.appType), - deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), - platform: parseString(req.query.platform), - arch: parseString(req.query.arch), - instanceMode: parseString(req.query.instanceMode), - currentVersion: parseString(req.query.currentVersion), - reportUsage: parseReportUsage(parseString(req.query.reportUsage)), - }); - res.json(updateInfo); - } catch (error) { - console.error('Failed to check for updates:', error); - res.status(500).json({ - available: false, - error: error instanceof Error ? error.message : 'Failed to check for updates', - }); - } - }); - - app.post('/api/openchamber/update-install', async (_req, res) => { - try { - const { spawn: spawnChild } = await import('child_process'); - const { - checkForUpdates, - getUpdateCommand, - detectPackageManager, - } = await import('./lib/package-manager.js'); - - // Verify update is available - const updateInfo = await checkForUpdates(); - if (!updateInfo.available) { - return res.status(400).json({ error: 'No update available' }); - } - - const pm = detectPackageManager(); - const updateCmd = getUpdateCommand(pm); - const isContainer = - fs.existsSync('/.dockerenv') || - Boolean(process.env.CONTAINER) || - process.env.container === 'docker'; - - if (isContainer) { - res.json({ - success: true, - message: 'Update starting, server will stay online', - version: updateInfo.version, - packageManager: pm, - autoRestart: false, - }); - - setTimeout(() => { - console.log(`\nInstalling update using ${pm} (container mode)...`); - console.log(`Running: ${updateCmd}`); - - const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - const child = spawnChild(shell, [shellFlag, updateCmd], { - detached: true, - stdio: 'ignore', - env: process.env, - }); - child.unref(); - }, 500); - - return; - } - - // Get current server port for restart - const currentPort = server.address()?.port || 3000; - - // Try to read stored instance options for restart - const tmpDir = os.tmpdir(); - const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`); - let storedOptions = { port: currentPort, daemon: true }; - try { - const content = await fs.promises.readFile(instanceFilePath, 'utf8'); - storedOptions = JSON.parse(content); - } catch { - // Use defaults - } - - const isWindows = process.platform === 'win32'; - - const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; - const quoteCmd = (value) => { - const stringValue = String(value); - return `"${stringValue.replace(/"/g, '""')}"`; - }; - - // Build restart command using explicit runtime + CLI path. - // Avoids relying on `openchamber` being in PATH for service environments. - const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js'); - const restartParts = [ - isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), - isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), - 'serve', - '--port', - String(storedOptions.port), - '--daemon', - ]; - let restartCmdPrimary = restartParts.join(' '); - let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; - if (storedOptions.uiPassword) { - if (isWindows) { - // Escape for cmd.exe quoted argument - const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); - restartCmdPrimary += ` --ui-password "${escapedPw}"`; - restartCmdFallback += ` --ui-password "${escapedPw}"`; - } else { - // Escape for POSIX single-quoted argument - const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); - restartCmdPrimary += ` --ui-password '${escapedPw}'`; - restartCmdFallback += ` --ui-password '${escapedPw}'`; - } - } - const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; - - // Respond immediately - update will happen after response - res.json({ - success: true, - message: 'Update starting, server will restart shortly', - version: updateInfo.version, - packageManager: pm, - autoRestart: true, - }); - - // Give time for response to be sent - setTimeout(() => { - console.log(`\nInstalling update using ${pm}...`); - console.log(`Running: ${updateCmd}`); - - // Create a script that will: - // 1. Wait for current process to exit - // 2. Run the update - // 3. Restart the server with original options - const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; - const shellFlag = isWindows ? '/c' : '-c'; - const script = isWindows - ? ` - timeout /t 2 /nobreak >nul - ${updateCmd} - if %ERRORLEVEL% EQU 0 ( - echo Update successful, restarting OpenChamber... - ${restartCmd} - ) else ( - echo Update failed - exit /b 1 - ) - ` - : ` - sleep 2 - ${updateCmd} - if [ $? -eq 0 ]; then - echo "Update successful, restarting OpenChamber..." - ${restartCmd} - else - echo "Update failed" - exit 1 - fi - `; - - // Spawn detached shell to run update after we exit. - // Capture output to disk so restart failures are diagnosable. - const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log'); - let logFd = null; - try { - fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); - logFd = fs.openSync(updateLogPath, 'a'); - } catch (logError) { - console.warn('Failed to open update log file, continuing without log capture:', logError); - } - - const child = spawnChild(shell, [shellFlag, script], { - detached: true, - stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', - env: process.env, - }); - child.unref(); - - if (logFd !== null) { - try { - fs.closeSync(logFd); - } catch { - // ignore - } - } - - console.log('Update process spawned, shutting down server...'); - - // Give child process time to start, then exit - setTimeout(() => { - process.exit(0); - }, 500); - }, 500); - } catch (error) { - console.error('Failed to install update:', error); - res.status(500).json({ - error: error instanceof Error ? error.message : 'Failed to install update', - }); - } - }); - - app.get('/api/openchamber/models-metadata', async (req, res) => { - const now = Date.now(); - - if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < MODELS_METADATA_CACHE_TTL) { - res.setHeader('Cache-Control', 'public, max-age=60'); - return res.json(cachedModelsMetadata); - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - - try { - const response = await fetch(MODELS_DEV_API_URL, { - signal: controller?.signal, - headers: { - Accept: 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`models.dev responded with status ${response.status}`); - } - - const metadata = await response.json(); - cachedModelsMetadata = metadata; - cachedModelsMetadataTimestamp = Date.now(); - - res.setHeader('Cache-Control', 'public, max-age=300'); - res.json(metadata); - } catch (error) { - console.warn('Failed to fetch models.dev metadata via server:', error); - - if (cachedModelsMetadata) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedModelsMetadata); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); - } - } finally { - if (timeout) { - clearTimeout(timeout); - } - } - }); - - // Zen models endpoint - returns available free models from the zen API - app.get('/api/zen/models', async (_req, res) => { - try { - const models = await fetchFreeZenModels(); - res.setHeader('Cache-Control', 'public, max-age=300'); - res.json({ models }); - } catch (error) { - console.warn('Failed to fetch zen models:', error); - // Serve stale cache if available - if (cachedZenModels) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedZenModels); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); - } - } - }); - - const tunnelService = createTunnelService({ - registry: tunnelProviderRegistry, - getController: () => activeTunnelController, - setController: (controller) => { - activeTunnelController = controller; + const startupPipelineResult = await startupPipelineRuntime.run({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, + terminalRebindWindowMs: TERMINAL_INPUT_WS_REBIND_WINDOW_MS, + terminalMaxRebindsPerWindow: TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW, + setupProxy, + scheduleOpenCodeApiDetection, + bootstrapOpenCodeAtStartup, + staticRoutesRuntime, + process, + crypto, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached: () => signalsAttached, + setSignalsAttached: (value) => { + signalsAttached = value; }, - getActivePort: () => activePort, - onQuickTunnelWarning: () => { - printTunnelWarning(); - }, - }); - - const resolveActiveNormalizedTunnelMode = () => { - const mode = tunnelService.resolveActiveMode(); - if (mode === TUNNEL_MODE_MANAGED_LOCAL) { - return TUNNEL_MODE_MANAGED_LOCAL; - } - if (mode === TUNNEL_MODE_MANAGED_REMOTE) { - return TUNNEL_MODE_MANAGED_REMOTE; - } - return TUNNEL_MODE_QUICK; - }; - - const resolveNormalizedTunnelHost = (publicUrl) => { - if (typeof publicUrl !== 'string' || publicUrl.trim().length === 0) { - return null; - } - try { - return new URL(publicUrl).hostname.toLowerCase(); - } catch { - return null; - } - }; - - const resolvePreferredTunnelProvider = async (reqBody = null) => { - if (typeof reqBody?.provider === 'string' && reqBody.provider.trim().length > 0) { - return normalizeTunnelProvider(reqBody.provider); - } - const activeProvider = tunnelService.resolveActiveProvider(); - if (activeProvider) { - return normalizeTunnelProvider(activeProvider); - } - const settings = await readSettingsFromDiskMigrated(); - return normalizeTunnelProvider(settings?.tunnelProvider); - }; - - const startTunnelWithNormalizedRequest = async ({ - provider, - mode, - intent, - hostname, - token, - configPath, - selectedPresetId, - selectedPresetName, - }) => { - if (provider === TUNNEL_PROVIDER_CLOUDFLARE && mode === TUNNEL_MODE_MANAGED_REMOTE) { - runtimeManagedRemoteTunnelHostname = hostname; - runtimeManagedRemoteTunnelToken = token; - - if (token && hostname) { - await upsertManagedRemoteTunnelToken({ - id: selectedPresetId || hostname, - name: selectedPresetName || hostname, - hostname, - token, - }); - } - } - - const result = await tunnelService.start({ - provider, - mode, - intent, - configPath, - token, - hostname, - }); - - console.log(`Tunnel active (${result.provider}): ${result.publicUrl}`); - return { - publicUrl: result.publicUrl, - mode: result.activeMode, - provider: result.provider, - providerMetadata: result.providerMetadata, - }; - }; - - const createGenericModeChecks = ({ modeKey, requiredFields, doctorRequest, startupReady }) => { - const checks = [ - { - id: 'startup_readiness', - label: 'Provider startup readiness', - status: startupReady ? 'pass' : 'fail', - detail: startupReady - ? 'Provider dependency checks passed.' - : 'Resolve provider checks before starting tunnels.', - }, - ]; - - for (const field of requiredFields) { - const value = doctorRequest?.[field]; - const present = typeof value === 'string' ? value.trim().length > 0 : Boolean(value); - checks.push({ - id: `requirement_${field}`, - label: `Required: ${field}`, - status: present ? 'pass' : 'fail', - detail: present - ? `${field} is configured.` - : `${field} is required for ${modeKey}.`, - }); - } - - const failures = checks.filter((entry) => entry.status === 'fail').length; - const warnings = checks.filter((entry) => entry.status === 'warn').length; - return { - mode: modeKey, - checks, - summary: { - ready: failures === 0, - failures, - warnings, - }, - ready: failures === 0, - blockers: checks - .filter((entry) => entry.status === 'fail' && entry.id !== 'startup_readiness') - .map((entry) => entry.detail || entry.label || entry.id), - }; - }; - - const runTunnelDoctor = async ({ providerId, modeFilter, doctorRequest }) => { - const provider = tunnelProviderRegistry.get(providerId); - if (!provider) { - throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`); - } - - const capabilities = provider.capabilities || {}; - const modeKeys = Array.isArray(capabilities.modes) - ? capabilities.modes.map((entry) => entry?.key).filter((key) => typeof key === 'string' && key.length > 0) - : []; - - if (modeFilter && !modeKeys.includes(modeFilter)) { - throw new TunnelServiceError('mode_unsupported', `Provider '${providerId}' does not support mode '${modeFilter}'`); - } - - if (typeof provider.diagnose === 'function') { - const diagnosed = await provider.diagnose({ - ...doctorRequest, - mode: modeFilter || doctorRequest?.mode, - }, { - capabilities, - }); - const providerChecks = Array.isArray(diagnosed?.providerChecks) ? diagnosed.providerChecks : []; - const allModes = Array.isArray(diagnosed?.modes) ? diagnosed.modes : []; - const modes = modeFilter ? allModes.filter((entry) => entry?.mode === modeFilter) : allModes; - return { - ok: true, - provider: providerId, - providerChecks, - modes, - }; - } - - const availability = await tunnelService.checkAvailability(providerId); - const dependencyAvailable = Boolean(availability?.available); - const providerChecks = [{ - id: 'dependency', - label: 'Provider dependency', - status: dependencyAvailable ? 'pass' : 'fail', - detail: dependencyAvailable - ? (availability?.version || 'available') - : (availability?.message || 'Required provider dependency is unavailable.'), - }]; - - const targetModes = (Array.isArray(capabilities.modes) ? capabilities.modes : []) - .filter((entry) => !modeFilter || entry?.key === modeFilter); - const modes = targetModes.map((entry) => createGenericModeChecks({ - modeKey: entry.key, - requiredFields: Array.isArray(entry?.requires) ? entry.requires : [], - doctorRequest, - startupReady: dependencyAvailable, - })); - - return { - ok: true, - provider: providerId, - providerChecks, - modes, - }; - }; - - // ── Tunnel API ───────────────────────────────────────────────────── - - app.get('/api/openchamber/tunnel/check', async (req, res) => { - try { - const requestedProvider = typeof req?.query?.provider === 'string' && req.query.provider.trim().length > 0 - ? normalizeTunnelProvider(req.query.provider) - : await resolvePreferredTunnelProvider(); - const result = await tunnelService.checkAvailability(requestedProvider); - res.json({ - available: result.available, - provider: requestedProvider, - version: result.version || null, - }); - } catch (error) { - console.warn('Tunnel dependency check failed:', error); - res.json({ available: false, provider: null, version: null }); - } - }); - - // Accept both POST (preferred, tokens in body) and GET (backward compat, no tokens in URL). - const handleTunnelDoctor = async (req, res) => { - try { - const params = req.query || {}; - // Sensitive fields (tokens) are read from the request body only, never from query params. - const body = req.body || {}; - - const providerId = typeof params.provider === 'string' && params.provider.trim().length > 0 - ? normalizeTunnelProvider(params.provider) - : await resolvePreferredTunnelProvider(); - const modeFilter = typeof params.mode === 'string' && params.mode.trim().length > 0 - ? params.mode.trim().toLowerCase() - : null; - - const settings = await readSettingsFromDiskMigrated(); - const selectedPresetId = typeof params.managedRemoteTunnelPresetId === 'string' - ? params.managedRemoteTunnelPresetId.trim() - : ''; - const requestConfigPath = normalizeOptionalPath(params.configPath) - ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); - const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(params.managedRemoteTunnelHostname); - const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(params.tunnelHostname); - const requestHostname = normalizeManagedRemoteTunnelHostname(params.hostname); - const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; - - const requestManagedRemoteToken = typeof body.managedRemoteTunnelToken === 'string' - ? body.managedRemoteTunnelToken.trim() - : ''; - const requestTunnelToken = typeof body.tunnelToken === 'string' - ? body.tunnelToken.trim() - : ''; - const requestToken = typeof body.token === 'string' - ? body.token.trim() - : ''; - const requestTokenProvided = body.managedRemoteTunnelTokenProvided === true - || body.tunnelTokenProvided === true - || body.tokenProvided === true; - const requestHostnameProvided = body.managedRemoteTunnelHostnameProvided === true - || body.tunnelHostnameProvided === true - || body.hostnameProvided === true; - const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' - ? settings.managedRemoteTunnelToken.trim() - : ''; - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const serverHasSavedManagedRemoteProfile = managedRemoteTunnelConfig.tunnels.some((entry) => { - const savedHostname = normalizeManagedRemoteTunnelHostname(entry?.hostname); - const savedToken = typeof entry?.token === 'string' ? entry.token.trim() : ''; - return Boolean(savedHostname && savedToken); - }); - const cliHasSavedManagedRemoteProfile = params.hasSavedManagedRemoteProfile === '1'; - const hasSavedManagedRemoteProfile = serverHasSavedManagedRemoteProfile || cliHasSavedManagedRemoteProfile; - const configManagedRemoteToken = providerId === TUNNEL_PROVIDER_CLOUDFLARE - ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) - : ''; - const token = requestToken - || requestTunnelToken - || requestManagedRemoteToken - || ((runtimeManagedRemoteTunnelHostname && hostname && runtimeManagedRemoteTunnelHostname === hostname) ? runtimeManagedRemoteTunnelToken : '') - || configManagedRemoteToken - || storedManagedRemoteToken; - - const doctorRequest = { - mode: modeFilter, - hostname, - token, - tokenProvided: requestTokenProvided, - hostnameProvided: requestHostnameProvided, - configPath: requestConfigPath, - hasSavedManagedRemoteProfile, - }; - - const result = await runTunnelDoctor({ - providerId, - modeFilter, - doctorRequest, - }); - return res.json(result); - } catch (error) { - if (error instanceof TunnelServiceError) { - return res.status(400).json({ ok: false, error: error.message, code: error.code }); - } - console.warn('Tunnel doctor failed:', error); - return res.status(500).json({ ok: false, error: 'Failed to run tunnel doctor' }); - } - }; - app.post('/api/openchamber/tunnel/doctor', handleTunnelDoctor); - app.get('/api/openchamber/tunnel/doctor', handleTunnelDoctor); - - app.get('/api/openchamber/tunnel/providers', (_req, res) => { - const providers = tunnelProviderRegistry.listCapabilities(); - return res.json({ providers }); - }); - - app.get('/api/openchamber/tunnel/status', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - const normalizedMode = normalizeTunnelMode(settings?.tunnelMode); - const managedRemoteHostname = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const managedRemoteTunnelPresetSummaries = managedRemoteTunnelConfig.tunnels.map((entry) => ({ - id: entry.id, - name: entry.name, - hostname: entry.hostname, - })); - const hasStoredManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; - const hasManagedRemoteTunnelToken = runtimeManagedRemoteTunnelToken.length > 0 || managedRemoteTunnelConfig.tunnels.length > 0 || hasStoredManagedRemoteToken; - const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); - const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - const activeSessions = tunnelAuthController.listTunnelSessions(); - const activeProvider = tunnelService.resolveActiveProvider(); - const provider = activeProvider || normalizeTunnelProvider(settings?.tunnelProvider); - - const publicUrl = tunnelService.getPublicUrl(); - if (!publicUrl) { - return res.json({ - active: false, - url: null, - mode: normalizedMode, - provider, - providerMetadata: null, - hasManagedRemoteTunnelToken, - managedRemoteTunnelHostname: managedRemoteHostname || null, - managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, - managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), - hasBootstrapToken: false, - bootstrapExpiresAt: null, - policy: 'tunnel-gated', - activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null, - activeSessions, - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } - - const activeNormalizedMode = resolveActiveNormalizedTunnelMode(); - const activeTunnelId = tunnelAuthController.getActiveTunnelId(); - const activeTunnelHost = tunnelAuthController.getActiveTunnelHost(); - const resolvedTunnelHost = resolveNormalizedTunnelHost(publicUrl); - const activeTunnelMode = tunnelAuthController.getActiveTunnelMode(); - const needsActiveTunnelSync = !activeTunnelId - || !activeTunnelHost - || !resolvedTunnelHost - || activeTunnelHost !== resolvedTunnelHost - || activeTunnelMode !== activeNormalizedMode; - if (needsActiveTunnelSync) { - tunnelAuthController.setActiveTunnel({ - tunnelId: activeTunnelId || crypto.randomUUID(), - publicUrl, - mode: activeNormalizedMode, - }); - } - - const bootstrapStatus = tunnelAuthController.getBootstrapStatus(); - const providerMetadata = tunnelService.getProviderMetadata(); - - return res.json({ - active: true, - url: publicUrl, - mode: activeNormalizedMode, - provider, - providerMetadata, - hasManagedRemoteTunnelToken, - managedRemoteTunnelHostname: managedRemoteHostname || null, - managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, - managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), - hasBootstrapToken: bootstrapStatus.hasBootstrapToken, - bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt, - policy: 'tunnel-gated', - activeTunnelMode: activeNormalizedMode, - activeSessions: tunnelAuthController.listTunnelSessions(), - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } catch (error) { - return res.status(500).json({ error: 'Failed to get tunnel status' }); - } - }); - - app.put('/api/openchamber/tunnel/managed-remote-token', async (req, res) => { - try { - // Token presets are currently Cloudflare-specific. - const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : ''; - const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : ''; - const managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(req?.body?.managedRemoteTunnelHostname); - const managedRemoteTunnelToken = typeof req?.body?.managedRemoteTunnelToken === 'string' ? req.body.managedRemoteTunnelToken.trim() : ''; - - if (!presetId || !presetName || !managedRemoteTunnelHostname || !managedRemoteTunnelToken) { - return res.status(400).json({ ok: false, error: 'presetId, presetName, managedRemoteTunnelHostname and managedRemoteTunnelToken are required' }); - } - - await upsertManagedRemoteTunnelToken({ - id: presetId, - name: presetName, - hostname: managedRemoteTunnelHostname, - token: managedRemoteTunnelToken, - }); - - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - return res.json({ ok: true, managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) }); - } catch (error) { - return res.status(500).json({ ok: false, error: 'Failed to save managed remote tunnel token' }); - } - }); - - app.post('/api/openchamber/tunnel/start', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - // Reject explicitly supplied unknown providers/modes early, before normalization converts them to defaults. - if (typeof _req?.body?.provider === 'string' && _req.body.provider.trim().length > 0) { - const rawProvider = _req.body.provider.trim().toLowerCase(); - if (!tunnelProviderRegistry.get(rawProvider)) { - return res.status(422).json({ ok: false, error: `Unsupported tunnel provider: ${rawProvider}`, code: 'provider_unsupported' }); - } - } - const provider = normalizeTunnelProvider(_req?.body?.provider ?? settings?.tunnelProvider); - const modeInput = _req?.body?.mode ?? settings?.tunnelMode; - const intent = typeof _req?.body?.intent === 'string' ? _req.body.intent.trim().toLowerCase() : undefined; - const mode = typeof modeInput === 'string' - ? modeInput.trim().toLowerCase() - : normalizeTunnelMode(modeInput); - if (typeof _req?.body?.mode === 'string' && _req.body.mode.trim().length > 0 && !isSupportedTunnelMode(mode)) { - return res.status(422).json({ ok: false, error: `Unsupported tunnel mode: ${mode}`, code: 'mode_unsupported' }); - } - const selectedPresetId = typeof _req?.body?.managedRemoteTunnelPresetId === 'string' ? _req.body.managedRemoteTunnelPresetId.trim() : ''; - const selectedPresetName = typeof _req?.body?.managedRemoteTunnelPresetName === 'string' ? _req.body.managedRemoteTunnelPresetName.trim() : ''; - const requestConfigPath = normalizeOptionalPath(_req?.body?.configPath) - ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); - const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.managedRemoteTunnelHostname); - const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.tunnelHostname); - const requestHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.hostname); - const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; - const requestManagedRemoteToken = typeof _req?.body?.managedRemoteTunnelToken === 'string' ? _req.body.managedRemoteTunnelToken.trim() : ''; - const requestTunnelToken = typeof _req?.body?.tunnelToken === 'string' ? _req.body.tunnelToken.trim() : ''; - const requestToken = typeof _req?.body?.token === 'string' ? _req.body.token.trim() : ''; - const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' ? settings.managedRemoteTunnelToken.trim() : ''; - const configManagedRemoteToken = provider === TUNNEL_PROVIDER_CLOUDFLARE - ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) - : ''; - const token = requestToken - || requestTunnelToken - || requestManagedRemoteToken - || ((runtimeManagedRemoteTunnelHostname && hostname && runtimeManagedRemoteTunnelHostname === hostname) ? runtimeManagedRemoteTunnelToken : '') - || configManagedRemoteToken - || storedManagedRemoteToken; - const requestConnectTtlMs = typeof _req?.body?.connectTtlMs === 'number' && Number.isFinite(_req.body.connectTtlMs) - ? normalizeTunnelBootstrapTtlMs(_req.body.connectTtlMs) - : undefined; - const requestSessionTtlMs = typeof _req?.body?.sessionTtlMs === 'number' && Number.isFinite(_req.body.sessionTtlMs) - ? normalizeTunnelSessionTtlMs(_req.body.sessionTtlMs) - : undefined; - const bootstrapTtlMs = requestConnectTtlMs ?? (settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs)); - const sessionTtlMs = requestSessionTtlMs ?? normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - - const previousTunnelId = tunnelAuthController.getActiveTunnelId(); - const previousMode = tunnelAuthController.getActiveTunnelMode(); - const previousProvider = tunnelService.resolveActiveProvider(); - const previousUrl = tunnelService.getPublicUrl(); - - const { publicUrl, provider: activeProvider, providerMetadata } = await startTunnelWithNormalizedRequest({ - provider, - mode, - intent, - hostname, - token, - configPath: requestConfigPath, - selectedPresetId, - selectedPresetName, - }); - - const replacedTunnel = Boolean(previousTunnelId) && ( - previousMode !== mode - || previousProvider !== activeProvider - || previousUrl !== publicUrl - ); - let revokedBootstrapCount = 0; - let invalidatedSessionCount = 0; - if (replacedTunnel && previousTunnelId) { - const revoked = tunnelAuthController.revokeTunnelArtifacts(previousTunnelId); - revokedBootstrapCount = revoked.revokedBootstrapCount; - invalidatedSessionCount = revoked.invalidatedSessionCount; - } - - tunnelAuthController.setActiveTunnel({ - tunnelId: replacedTunnel || !previousTunnelId ? crypto.randomUUID() : previousTunnelId, - publicUrl, - mode, - }); - - const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); - const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const isCloudflareProvider = activeProvider === TUNNEL_PROVIDER_CLOUDFLARE; - - return res.json({ - ok: true, - url: publicUrl, - mode, - provider: activeProvider, - providerMetadata, - managedRemoteTunnelHostname: isCloudflareProvider ? (hostname || null) : null, - managedRemoteTunnelTokenPresetIds: isCloudflareProvider ? managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) : [], - connectUrl, - bootstrapExpiresAt: bootstrapToken.expiresAt, - replacedTunnel, - replaced: replacedTunnel - ? { - mode: previousMode, - provider: previousProvider, - url: previousUrl, - } - : null, - revokedBootstrapCount, - invalidatedSessionCount, - policy: 'tunnel-gated', - activeTunnelMode: mode, - activeSessions: tunnelAuthController.listTunnelSessions(), - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } catch (error) { - console.error('Failed to start tunnel:', error); - activeTunnelController = null; - tunnelAuthController.clearActiveTunnel(); - if (error instanceof TunnelServiceError) { - const status = error.code === 'missing_dependency' - ? 400 - : (error.code === 'validation_error' || error.code === 'provider_unsupported' || error.code === 'mode_unsupported' - ? 422 - : 500); - return res.status(status).json({ ok: false, error: error.message, code: error.code }); - } - return res.status(500).json({ ok: false, error: 'Failed to start tunnel', code: 'startup_failed' }); - } - }); - - app.post('/api/openchamber/tunnel/stop', (_req, res) => { - let revokedBootstrapCount = 0; - let invalidatedSessionCount = 0; - const activeTunnelId = tunnelAuthController.getActiveTunnelId(); - - if (activeTunnelId) { - const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId); - revokedBootstrapCount = revoked.revokedBootstrapCount; - invalidatedSessionCount = revoked.invalidatedSessionCount; - } - - if (activeTunnelController) { - console.log('Stopping active tunnel (user requested)...'); - tunnelService.stop(); - } - - tunnelAuthController.clearActiveTunnel(); - res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount }); - }); - - // ── End Tunnel API ──────────────────────────────────────────────── - - app.get('/api/global/event', async (req, res) => { - let targetUrl; - try { - targetUrl = new URL(buildOpenCodeUrl('/global/event', '')); - } catch { - return res.status(503).json({ error: 'OpenCode service unavailable' }); - } - - const headers = { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }; - - const lastEventId = req.header('Last-Event-ID'); - if (typeof lastEventId === 'string' && lastEventId.length > 0) { - headers['Last-Event-ID'] = lastEventId; - } - - const controller = new AbortController(); - const cleanup = () => { - if (!controller.signal.aborted) { - controller.abort(); - } - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - let upstream; - try { - upstream = await fetch(targetUrl.toString(), { - headers, - signal: controller.signal, - }); - } catch (error) { - return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' }); - } - - if (!upstream.ok || !upstream.body) { - return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - uiNotificationClients.add(res); - const cleanupClient = () => { - uiNotificationClients.delete(res); - }; - req.on('close', cleanupClient); - req.on('error', cleanupClient); - - const heartbeatInterval = setInterval(() => { - writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); - }, 15000); - - const decoder = new TextDecoder(); - const reader = upstream.body.getReader(); - let buffer = ''; - - const forwardBlock = (block) => { - if (!block) return; - const payload = parseSseDataPayload(block); - - res.write(`${block} - -`); - // Cache session titles from session.updated/session.created events (global stream) - maybeCacheSessionInfoFromEvent(payload); - - // Keep server-authoritative session state fresh even if the - // background watcher is disconnected. - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - if (setSessionActivityPhase(activity.sessionId, activity.phase)) { - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, - } - }); - } - } - } - }; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - forwardBlock(block); - separatorIndex = buffer.indexOf('\n\n'); - } - } - - if (buffer.trim().length > 0) { - forwardBlock(buffer.trim()); - } - } catch (error) { - if (!controller.signal.aborted) { - console.warn('SSE proxy stream error:', error); - } - } finally { - clearInterval(heartbeatInterval); - cleanupClient(); - cleanup(); - try { - res.end(); - } catch { - // ignore - } - } - }); - - app.get('/api/event', async (req, res) => { - let targetUrl; - try { - targetUrl = new URL(buildOpenCodeUrl('/event', '')); - } catch { - return res.status(503).json({ error: 'OpenCode service unavailable' }); - } - - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const directoryParam = Array.isArray(req.query.directory) - ? req.query.directory[0] - : req.query.directory; - const resolvedDirectory = headerDirectory || directoryParam || null; - if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) { - targetUrl.searchParams.set('directory', resolvedDirectory.trim()); - } - - const headers = { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }; - - const lastEventId = req.header('Last-Event-ID'); - if (typeof lastEventId === 'string' && lastEventId.length > 0) { - headers['Last-Event-ID'] = lastEventId; - } - - const controller = new AbortController(); - const cleanup = () => { - if (!controller.signal.aborted) { - controller.abort(); - } - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - let upstream; - try { - upstream = await fetch(targetUrl.toString(), { - headers, - signal: controller.signal, - }); - } catch (error) { - return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' }); - } - - if (!upstream.ok || !upstream.body) { - return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - const heartbeatInterval = setInterval(() => { - writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); - }, 15000); - - const decoder = new TextDecoder(); - const reader = upstream.body.getReader(); - let buffer = ''; - - const forwardBlock = (block) => { - if (!block) return; - const payload = parseSseDataPayload(block); - - res.write(`${block} - -`); - // Cache session titles from session.updated/session.created events (per-session stream) - maybeCacheSessionInfoFromEvent(payload); - - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - if (setSessionActivityPhase(activity.sessionId, activity.phase)) { - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, - } - }); - } - } - } - }; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - forwardBlock(block); - separatorIndex = buffer.indexOf('\n\n'); - } - } - - if (buffer.trim().length > 0) { - forwardBlock(buffer.trim()); - } - } catch (error) { - if (!controller.signal.aborted) { - console.warn('SSE proxy stream error:', error); - } - } finally { - clearInterval(heartbeatInterval); - cleanup(); - try { - res.end(); - } catch { - // ignore - } - } - }); - - app.get('/api/config/settings', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - res.json(formatSettingsResponse(settings)); - } catch (error) { - console.error('Failed to load settings:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load settings' }); - } - }); - - app.get('/api/config/opencode-resolution', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null; - - const previousSource = resolvedOpencodeBinarySource; - const detectedNow = resolveOpencodeCliPath(); - const rawDetectedSourceNow = resolvedOpencodeBinarySource; - resolvedOpencodeBinarySource = previousSource; - - // Best-effort: apply configured override (if any) and resolve. - await applyOpencodeBinaryFromSettings(); - ensureOpencodeCliEnv(); - - const resolved = resolvedOpencodeBinary || null; - const source = resolvedOpencodeBinarySource || null; - const detectedSourceNow = - detectedNow && - resolved && - detectedNow === resolved && - rawDetectedSourceNow === 'env' && - source && - source !== 'env' - ? source - : rawDetectedSourceNow; - const shim = resolved ? opencodeShimInterpreter(resolved) : null; - - res.json({ - configured, - resolved, - resolvedDir: resolved ? path.dirname(resolved) : null, - source, - detectedNow, - detectedSourceNow, - shim, - viaWsl: useWslForOpencode, - wslBinary: resolvedWslBinary || null, - wslPath: resolvedWslOpencodePath || null, - wslDistro: resolvedWslDistro || null, - node: resolvedNodeBinary || null, - bun: resolvedBunBinary || null, - }); - } catch (error) { - console.error('Failed to build opencode resolution snapshot:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build snapshot' }); - } - }); - - app.get('/api/config/themes', async (_req, res) => { - try { - const customThemes = await readCustomThemesFromDisk(); - res.json({ themes: customThemes }); - } catch (error) { - console.error('Failed to load custom themes:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' }); - } - }); - - app.put('/api/config/settings', async (req, res) => { - console.log(`[API:PUT /api/config/settings] Received request`); - try { - const updated = await persistSettings(req.body ?? {}); - console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`); - res.json(updated); - } catch (error) { - console.error(`[API:PUT /api/config/settings] Failed to save settings:`, error); - console.error(`[API:PUT /api/config/settings] Error stack:`, error.stack); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save settings' }); - } - }); - - app.get('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const metadataMime = normalizeProjectIconMime(project.iconImage?.mime); - const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null; - const candidates = preferredPath - ? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)] - : projectIconPathCandidates(projectId); - - const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme; - const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery); - const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor; - const requestedIconColor = normalizeProjectIconColor(iconColorQuery); - - for (const iconPath of candidates) { - try { - const data = await fsPromises.readFile(iconPath); - const ext = path.extname(iconPath).slice(1).toLowerCase(); - const resolvedMime = metadataMime || PROJECT_ICON_EXTENSION_TO_MIME[ext] || 'application/octet-stream'; - const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime; - - if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) { - const svgMarkup = data.toString('utf8'); - const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(themedSvgMarkup); - } - - if (resolvedMime === 'image/svg+xml' && requestedIconColor) { - const svgMarkup = data.toString('utf8'); - const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(themedSvgMarkup); - } - - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(data); - } catch (error) { - if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { - console.warn('Failed to read project icon:', error); - return res.status(500).json({ error: 'Failed to read project icon' }); - } - } - } - - return res.status(404).json({ error: 'Project icon not found' }); - } catch (error) { - console.warn('Failed to load project icon:', error); - return res.status(500).json({ error: 'Failed to load project icon' }); - } - }); - - app.put('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - const parsed = parseProjectIconDataUrl(req.body?.dataUrl); - if (!parsed.ok) { - return res.status(400).json({ error: parsed.error }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const iconPath = projectIconPathForMime(projectId, parsed.mime); - if (!iconPath) { - return res.status(400).json({ error: 'Unsupported icon format' }); - } - - await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true }); - await fsPromises.writeFile(iconPath, parsed.bytes); - await removeProjectIconFiles(projectId, iconPath); - - const updatedAt = Date.now(); - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ project: updatedProject, settings: updatedSettings }); - } catch (error) { - console.warn('Failed to upload project icon:', error); - return res.status(500).json({ error: 'Failed to upload project icon' }); - } - }); - - app.delete('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - await removeProjectIconFiles(projectId); - - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: null } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ project: updatedProject, settings: updatedSettings }); - } catch (error) { - console.warn('Failed to remove project icon:', error); - return res.status(500).json({ error: 'Failed to remove project icon' }); - } - }); - - app.post('/api/projects/:projectId/icon/discover', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const force = req.body?.force === true; - if (project.iconImage?.source === 'custom' && !force) { - return res.json({ - project, - skipped: true, - reason: 'custom-icon-present', - }); - } - - const faviconCandidates = await searchFilesystemFiles(project.path, { - limit: 200, - query: 'favicon', - includeHidden: true, - respectGitignore: false, - }); - - const filtered = faviconCandidates - .filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path)) - .sort((a, b) => a.path.length - b.path.length); - - const selected = filtered[0]; - if (!selected) { - return res.status(404).json({ error: 'No favicon found in project' }); - } - - const ext = path.extname(selected.path).slice(1).toLowerCase(); - const mime = PROJECT_ICON_EXTENSION_TO_MIME[ext] || null; - if (!mime) { - return res.status(415).json({ error: 'Unsupported favicon format' }); - } - - const bytes = await fsPromises.readFile(selected.path); - if (bytes.length === 0) { - return res.status(400).json({ error: 'Discovered icon is empty' }); - } - if (bytes.length > PROJECT_ICON_MAX_BYTES) { - return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' }); - } - - const iconPath = projectIconPathForMime(projectId, mime); - if (!iconPath) { - return res.status(415).json({ error: 'Unsupported favicon format' }); - } - - await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true }); - await fsPromises.writeFile(iconPath, bytes); - await removeProjectIconFiles(projectId, iconPath); - - const updatedAt = Date.now(); - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ - project: updatedProject, - settings: updatedSettings, - discoveredPath: selected.path, - }); - } catch (error) { - console.warn('Failed to discover project icon:', error); - return res.status(500).json({ error: 'Failed to discover project icon' }); - } - }); - - const { - getAgentSources, - getAgentScope, - getAgentConfig, - createAgent, - updateAgent, - deleteAgent, - getCommandSources, - getCommandScope, - createCommand, - updateCommand, - deleteCommand, - getProviderSources, - removeProviderConfig, - AGENT_SCOPE, - COMMAND_SCOPE, - listMcpConfigs, - getMcpConfig, - createMcpConfig, - updateMcpConfig, - deleteMcpConfig, - } = await import('./lib/opencode/index.js'); - - app.get('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const sources = getAgentSources(agentName, directory); - - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - - res.json({ - name: agentName, - sources: sources, - scope, - isBuiltIn: !sources.md.exists && !sources.json.exists - }); - } catch (error) { - console.error('Failed to get agent sources:', error); - res.status(500).json({ error: 'Failed to get agent configuration metadata' }); - } - }); - - app.get('/api/config/agents/:name/config', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const configInfo = getAgentConfig(agentName, directory); - res.json(configInfo); - } catch (error) { - console.error('Failed to get agent config:', error); - res.status(500).json({ error: 'Failed to get agent configuration' }); - } - }); - - app.post('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { scope, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating agent:', agentName); - console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createAgent(agentName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('agent creation', { - agentName - }); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create agent:', error); - res.status(500).json({ error: error.message || 'Failed to create agent' }); - } - }); - - app.patch('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating agent: ${agentName}`); - console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', directory); - - updateAgent(agentName, updates, directory); - await refreshOpenCodeAfterConfigChange('agent update'); - - console.log(`[Server] Agent ${agentName} updated successfully`); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update agent:', error); - console.error('[Server] Error stack:', error.stack); - res.status(500).json({ error: error.message || 'Failed to update agent' }); - } - }); - - app.delete('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteAgent(agentName, directory); - await refreshOpenCodeAfterConfigChange('agent deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete agent:', error); - res.status(500).json({ error: error.message || 'Failed to delete agent' }); - } - }); - - // ============================================================ - // MCP Config Routes - // ============================================================ - - app.get('/api/config/mcp', async (req, res) => { - try { - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - const configs = listMcpConfigs(directory); - res.json(configs); - } catch (error) { - console.error('[API:GET /api/config/mcp] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to list MCP configs' }); - } - }); - - app.get('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - const config = getMcpConfig(name, directory); - if (!config) { - return res.status(404).json({ error: `MCP server "${name}" not found` }); - } - res.json(config); - } catch (error) { - console.error('[API:GET /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to get MCP config' }); - } - }); - - app.post('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { scope, ...config } = req.body || {}; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`); - - createMcpConfig(name, config, directory, scope); - await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name }); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" created. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:POST /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to create MCP server' }); - } - }); - - app.patch('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const updates = req.body; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`); - - updateMcpConfig(name, updates, directory); - await refreshOpenCodeAfterConfigChange('mcp update'); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" updated. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:PATCH /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to update MCP server' }); - } - }); - - app.delete('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`); - - deleteMcpConfig(name, directory); - await refreshOpenCodeAfterConfigChange('mcp deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" deleted. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:DELETE /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to delete MCP server' }); - } - }); - - app.get('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const sources = getCommandSources(commandName, directory); - - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - - res.json({ - name: commandName, - sources: sources, - scope, - isBuiltIn: !sources.md.exists && !sources.json.exists - }); - } catch (error) { - console.error('Failed to get command sources:', error); - res.status(500).json({ error: 'Failed to get command configuration metadata' }); - } - }); - - app.post('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { scope, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating command:', commandName); - console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createCommand(commandName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('command creation', { - commandName - }); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create command:', error); - res.status(500).json({ error: error.message || 'Failed to create command' }); - } - }); - - app.patch('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating command: ${commandName}`); - console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', directory); - - updateCommand(commandName, updates, directory); - await refreshOpenCodeAfterConfigChange('command update'); - - console.log(`[Server] Command ${commandName} updated successfully`); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update command:', error); - console.error('[Server] Error stack:', error.stack); - res.status(500).json({ error: error.message || 'Failed to update command' }); - } - }); - - app.delete('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteCommand(commandName, directory); - await refreshOpenCodeAfterConfigChange('command deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete command:', error); - res.status(500).json({ error: error.message || 'Failed to delete command' }); - } - }); - - // ============== SKILL ENDPOINTS ============== - - const { - getSkillSources, - discoverSkills, - createSkill, - updateSkill, - deleteSkill, - readSkillSupportingFile, - writeSkillSupportingFile, - deleteSkillSupportingFile, - SKILL_SCOPE, - SKILL_DIR, - } = await import('./lib/opencode/index.js'); - - const findWorktreeRootForSkills = (workingDirectory) => { - if (!workingDirectory) return null; - let current = path.resolve(workingDirectory); - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } - }; - - const getSkillProjectAncestors = (workingDirectory) => { - if (!workingDirectory) return []; - const result = []; - let current = path.resolve(workingDirectory); - const stop = findWorktreeRootForSkills(workingDirectory) || current; - while (true) { - result.push(current); - if (current === stop) break; - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return result; - }; - - const isPathInside = (candidatePath, parentPath) => { - if (!candidatePath || !parentPath) return false; - const normalizedCandidate = path.resolve(candidatePath); - const normalizedParent = path.resolve(parentPath); - return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); - }; - - const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => { - const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : ''; - const home = os.homedir(); - const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) - ? 'agents' - : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) - ? 'claude' - : 'opencode'; - - const projectAncestors = getSkillProjectAncestors(workingDirectory); - const isProjectScoped = projectAncestors.some((ancestor) => { - const candidates = [ - path.join(ancestor, '.opencode'), - path.join(ancestor, '.claude', 'skills'), - path.join(ancestor, '.agents', 'skills'), - ]; - return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); - }); - - if (isProjectScoped) { - return { scope: SKILL_SCOPE.PROJECT, source }; - } - - const userRoots = [ - path.join(home, '.config', 'opencode'), - path.join(home, '.opencode'), - path.join(home, '.claude', 'skills'), - path.join(home, '.agents', 'skills'), - process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, - ].filter(Boolean); - - if (userRoots.some((root) => isPathInside(resolvedPath, root))) { - return { scope: SKILL_SCOPE.USER, source }; - } - - return { scope: SKILL_SCOPE.USER, source }; - }; - - const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => { - if (!openCodePort) { - return null; - } - - try { - const url = new URL(buildOpenCodeUrl('/skill', '')); - if (workingDirectory) { - url.searchParams.set('directory', workingDirectory); - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json(); - if (!Array.isArray(payload)) { - return null; - } - - return payload - .map((item) => { - const name = typeof item?.name === 'string' ? item.name.trim() : ''; - const location = typeof item?.location === 'string' ? item.location : ''; - const description = typeof item?.description === 'string' ? item.description : ''; - if (!name || !location) { - return null; - } - const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory); - return { - name, - path: location, - scope: inferred.scope, - source: inferred.source, - description, - }; - }) - .filter(Boolean); - } catch { - return null; - } - }; - - // List all discovered skills - app.get('/api/config/skills', async (req, res) => { - try { - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory); - - // Enrich with full sources info - const enrichedSkills = skills.map(skill => { - const sources = getSkillSources(skill.name, directory, skill); - return { - ...skill, - sources - }; - }); - - res.json({ skills: enrichedSkills }); - } catch (error) { - console.error('Failed to list skills:', error); - res.status(500).json({ error: 'Failed to list skills' }); - } - }); - - // ============== SKILLS CATALOG + INSTALL ENDPOINTS ============== - - const { - getCuratedSkillsSources, - getCacheKey, - getCachedScan, - setCachedScan, - parseSkillRepoSource, - scanSkillsRepository, - installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, - } = await import('./lib/skills-catalog/index.js'); - const { getProfiles, getProfile } = await import('./lib/git/index.js'); - - const listGitIdentitiesForResponse = () => { - try { - const profiles = getProfiles(); - return profiles.map((p) => ({ id: p.id, name: p.name })); - } catch { - return []; - } - }; - - const resolveGitIdentity = (profileId) => { - if (!profileId) { - return null; - } - try { - const profile = getProfile(profileId); - const sshKey = profile?.sshKey; - if (typeof sshKey === 'string' && sshKey.trim()) { - return { sshKey: sshKey.trim() }; - } - } catch { - // ignore - } - return null; - }; - - app.get('/api/config/skills/catalog', async (req, res) => { - try { - const { error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - - const curatedSources = getCuratedSkillsSources(); - const settings = await readSettingsFromDisk(); - const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; - - const customSources = customSourcesRaw.map((entry) => ({ - id: entry.id, - label: entry.label, - description: entry.source, - source: entry.source, - defaultSubpath: entry.subpath, - gitIdentityId: entry.gitIdentityId, - })); - - const sources = [...curatedSources, ...customSources]; - const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); - - res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); - } catch (error) { - console.error('Failed to load skills catalog:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); - } - }); - - app.get('/api/config/skills/catalog/source', async (req, res) => { - try { - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } }); - } - - const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null; - if (!sourceId) { - return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } }); - } - - const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; - - const curatedSources = getCuratedSkillsSources(); - const settings = await readSettingsFromDisk(); - const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; - - const customSources = customSourcesRaw.map((entry) => ({ - id: entry.id, - label: entry.label, - description: entry.source, - source: entry.source, - defaultSubpath: entry.subpath, - gitIdentityId: entry.gitIdentityId, - })); - - const sources = [...curatedSources, ...customSources]; - const src = sources.find((entry) => entry.id === sourceId); - - if (!src) { - return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } }); - } - - const discovered = directory - ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory)) - : []; - const installedByName = new Map(discovered.map((s) => [s.name, s])); - - if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { - const scanned = await scanClawdHubPage({ cursor: cursor || null }); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - const items = (scanned.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - ...item, - sourceId: src.id, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); - } - - const parsed = parseSkillRepoSource(src.source); - if (!parsed.ok) { - return res.status(400).json({ ok: false, error: parsed.error }); - } - - const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null; - const cacheKey = getCacheKey({ - normalizedRepo: parsed.normalizedRepo, - subpath: effectiveSubpath || '', - identityId: src.gitIdentityId || '', - }); - - let scanResult = !refresh ? getCachedScan(cacheKey) : null; - if (!scanResult) { - const scanned = await scanSkillsRepository({ - source: src.source, - subpath: src.defaultSubpath, - defaultSubpath: src.defaultSubpath, - identity: resolveGitIdentity(src.gitIdentityId), - }); - - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - scanResult = scanned; - setCachedScan(cacheKey, scanResult); - } - - const items = (scanResult.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - sourceId: src.id, - ...item, - gitIdentityId: src.gitIdentityId, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items }); - } catch (error) { - console.error('Failed to load catalog source:', error); - return res.status(500).json({ - ok: false, - error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' }, - }); - } - }); - - app.post('/api/config/skills/scan', async (req, res) => { - try { - const { source, subpath, gitIdentityId } = req.body || {}; - const identity = resolveGitIdentity(gitIdentityId); - - const result = await scanSkillsRepository({ - source, - subpath, - identity, - }); - - if (!result.ok) { - if (result.error?.kind === 'authRequired') { - return res.status(401).json({ - ok: false, - error: { - ...result.error, - identities: listGitIdentitiesForResponse(), - }, - }); - } - - return res.status(400).json({ ok: false, error: result.error }); - } - - res.json({ ok: true, items: result.items }); - } catch (error) { - console.error('Failed to scan skills repository:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } }); - } - }); - - app.post('/api/config/skills/install', async (req, res) => { - try { - const { - source, - subpath, - gitIdentityId, - scope, - targetSource, - selections, - conflictPolicy, - conflictDecisions, - } = req.body || {}; - - let workingDirectory = null; - if (scope === 'project') { - const resolved = await resolveProjectDirectory(req); - if (!resolved.directory) { - return res.status(400).json({ - ok: false, - error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' }, - }); - } - workingDirectory = resolved.directory; - } - - // Handle ClawdHub sources (ZIP download based) - if (isClawdHubSource(source)) { - const result = await installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } - - return res.json({ - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } - - // Handle GitHub sources (git clone based) - const identity = resolveGitIdentity(gitIdentityId); - - const result = await installSkillsFromRepository({ - source, - subpath, - identity, - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - - if (result.error?.kind === 'authRequired') { - return res.status(401).json({ - ok: false, - error: { - ...result.error, - identities: listGitIdentitiesForResponse(), - }, - }); - } - - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } - - res.json({ - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } catch (error) { - console.error('Failed to install skills:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); - } - }); - - // Get single skill sources - app.get('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - - res.json({ - name: skillName, - sources: sources, - scope: sources.md.scope, - source: sources.md.source, - exists: sources.md.exists - }); - } catch (error) { - console.error('Failed to get skill sources:', error); - res.status(500).json({ error: 'Failed to get skill configuration metadata' }); - } - }); - - // Get skill supporting file content - app.get('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - const content = readSkillSupportingFile(sources.md.dir, filePath); - if (content === null) { - return res.status(404).json({ error: 'File not found' }); - } - - res.json({ path: filePath, content }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read skill file:', error); - res.status(500).json({ error: 'Failed to read skill file' }); - } - }); - - // Create new skill - app.post('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { scope, source: skillSource, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating skill:', skillName); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createSkill(skillName, { ...config, source: skillSource }, directory, scope); - await refreshOpenCodeAfterConfigChange('skill creation'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create skill:', error); - res.status(500).json({ error: error.message || 'Failed to create skill' }); - } - }); - - // Update existing skill - app.patch('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating skill: ${skillName}`); - console.log('[Server] Working directory:', directory); - - updateSkill(skillName, updates, directory); - await refreshOpenCodeAfterConfigChange('skill update'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update skill:', error); - res.status(500).json({ error: error.message || 'Failed to update skill' }); - } - }); - - // Update/create supporting file - app.put('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { content } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - writeSkillSupportingFile(sources.md.dir, filePath, content || ''); - - res.json({ - success: true, - message: `File ${filePath} saved successfully`, - }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to write skill file:', error); - res.status(500).json({ error: error.message || 'Failed to write skill file' }); - } - }); - - // Delete supporting file - app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - deleteSkillSupportingFile(sources.md.dir, filePath); - - res.json({ - success: true, - message: `File ${filePath} deleted successfully`, - }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to delete skill file:', error); - res.status(500).json({ error: error.message || 'Failed to delete skill file' }); - } - }); - - // Delete skill - app.delete('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteSkill(skillName, directory); - await refreshOpenCodeAfterConfigChange('skill deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete skill:', error); - res.status(500).json({ error: error.message || 'Failed to delete skill' }); - } - }); - - app.post('/api/config/reload', async (req, res) => { - try { - console.log('[Server] Manual configuration reload requested'); - - await refreshOpenCodeAfterConfigChange('manual configuration reload'); - - res.json({ - success: true, - requiresReload: true, - message: 'Configuration reloaded successfully. Refreshing interface…', - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to reload configuration:', error); - res.status(500).json({ - error: error.message || 'Failed to reload configuration', - success: false - }); - } - }); - - let authLibrary = null; - const getAuthLibrary = async () => { - if (!authLibrary) { - authLibrary = await import('./lib/opencode/auth.js'); - } - return authLibrary; - }; - - let quotaProviders = null; - const getQuotaProviders = async () => { - if (!quotaProviders) { - quotaProviders = await import('./lib/quota/index.js'); - } - return quotaProviders; - }; - - // ================= GitHub OAuth (Device Flow) ================= - - // Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see lib/github/auth.js). - - let githubLibraries = null; - const getGitHubLibraries = async () => { - if (!githubLibraries) { - githubLibraries = await import('./lib/github/index.js'); - } - return githubLibraries; - }; - - const getGitHubUserSummary = async (octokit) => { - const me = await octokit.rest.users.getAuthenticated(); - - let email = typeof me.data.email === 'string' ? me.data.email : null; - if (!email) { - try { - const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 }); - const list = Array.isArray(emails?.data) ? emails.data : []; - const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string'); - const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string'); - email = primaryVerified?.email || anyVerified?.email || null; - } catch { - // ignore (scope might be missing) - } - } - - return { - login: me.data.login, - id: me.data.id, - avatarUrl: me.data.avatar_url, - name: typeof me.data.name === 'string' ? me.data.name : null, - email, - }; - }; - - const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403; - const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404; - - app.get('/api/github/auth/status', async (_req, res) => { - try { - const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const auth = getGitHubAuth(); - const accounts = getGitHubAuthAccounts(); - if (!auth?.accessToken) { - return res.json({ connected: false, accounts }); - } - - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false, accounts }); - } - - let user = null; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); - } - } - - const fallback = auth.user; - const mergedUser = user || fallback; - - return res.json({ - connected: true, - user: mergedUser, - scope: auth.scope, - accounts, - }); - } catch (error) { - console.error('Failed to get GitHub auth status:', error); - return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' }); - } - }); - - app.post('/api/github/auth/start', async (_req, res) => { - try { - const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); - const clientId = getGitHubClientId(); - if (!clientId) { - return res.status(400).json({ - error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', - }); - } - - const scope = getGitHubScopes(); - - const payload = await startDeviceFlow({ - clientId, - scope, - }); - - return res.json({ - deviceCode: payload.device_code, - userCode: payload.user_code, - verificationUri: payload.verification_uri, - verificationUriComplete: payload.verification_uri_complete, - expiresIn: payload.expires_in, - interval: payload.interval, - scope, - }); - } catch (error) { - console.error('Failed to start GitHub device flow:', error); - return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' }); - } - }); - - app.post('/api/github/auth/complete', async (req, res) => { - try { - const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const clientId = getGitHubClientId(); - if (!clientId) { - return res.status(400).json({ - error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', - }); - } - - const deviceCode = typeof req.body?.deviceCode === 'string' - ? req.body.deviceCode - : (typeof req.body?.device_code === 'string' ? req.body.device_code : ''); - - if (!deviceCode) { - return res.status(400).json({ error: 'deviceCode is required' }); - } - - const payload = await exchangeDeviceCode({ clientId, deviceCode }); - - if (payload?.error) { - return res.json({ - connected: false, - status: payload.error, - error: payload.error_description || payload.error, - }); - } - - const accessToken = payload?.access_token; - if (!accessToken) { - return res.status(500).json({ error: 'Missing access_token from GitHub' }); - } - - const { Octokit } = await import('@octokit/rest'); - const octokit = new Octokit({ auth: accessToken }); - const user = await getGitHubUserSummary(octokit); - - setGitHubAuth({ - accessToken, - scope: typeof payload.scope === 'string' ? payload.scope : '', - tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer', - user, - }); - - return res.json({ - connected: true, - user, - scope: typeof payload.scope === 'string' ? payload.scope : '', - accounts: getGitHubAuthAccounts(), - }); - } catch (error) { - console.error('Failed to complete GitHub device flow:', error); - return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' }); - } - }); - - app.post('/api/github/auth/activate', async (req, res) => { - try { - const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : ''; - if (!accountId) { - return res.status(400).json({ error: 'accountId is required' }); - } - const activated = activateGitHubAuth(accountId); - if (!activated) { - return res.status(404).json({ error: 'GitHub account not found' }); - } - - const auth = getGitHubAuth(); - const accounts = getGitHubAuthAccounts(); - if (!auth?.accessToken) { - return res.json({ connected: false, accounts }); - } - - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false, accounts }); - } - - let user = auth.user || null; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); - } - } - - return res.json({ - connected: true, - user, - scope: auth.scope, - accounts, - }); - } catch (error) { - console.error('Failed to activate GitHub account:', error); - return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' }); - } - }); - - app.delete('/api/github/auth', async (_req, res) => { - try { - const { clearGitHubAuth } = await getGitHubLibraries(); - const removed = clearGitHubAuth(); - return res.json({ success: true, removed }); - } catch (error) { - console.error('Failed to disconnect GitHub:', error); - return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' }); - } - }); - - app.get('/api/github/me', async (_req, res) => { - try { - const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - let user; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.status(401).json({ error: 'GitHub token expired or revoked' }); - } - throw error; - } - return res.json(user); - } catch (error) { - console.error('Failed to fetch GitHub user:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' }); - } - }); - - // ================= GitHub PR APIs ================= - - app.get('/api/github/pr/status', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; - const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; - if (!directory || !branch) { - return res.status(400).json({ error: 'directory and branch are required' }); - } - - const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubPrStatus } = await import('./lib/github/pr-status.js'); - const resolvedStatus = await resolveGitHubPrStatus({ - octokit, - directory, - branch, - remoteName: remote, - }); - const searchRepo = resolvedStatus.repo; - const first = resolvedStatus.pr; - if (!searchRepo) { - return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null }); - } - if (!first) { - return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null }); - } - - // Enrich with mergeability fields - const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number }); - const prData = prFull?.data; - if (!prData) { - return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); - } - - // Checks summary: prefer check-runs (Actions), fallback to classic statuses. - let checks = null; - const sha = prData.head?.sha; - if (sha) { - try { - const runs = await octokit.rest.checks.listForRef({ - owner: searchRepo.owner, - repo: searchRepo.repo, - ref: sha, - per_page: 100, - }); - const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; - if (checkRuns.length > 0) { - const counts = { success: 0, failure: 0, pending: 0 }; - for (const run of checkRuns) { - const status = run?.status; - const conclusion = run?.conclusion; - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - continue; - } - if (!conclusion) { - counts.pending += 1; - continue; - } - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { - counts.success += 1; - } else { - counts.failure += 1; - } - } - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } - } catch { - // ignore and fall back - } - - if (!checks) { - try { - const combined = await octokit.rest.repos.getCombinedStatusForRef({ - owner: searchRepo.owner, - repo: searchRepo.repo, - ref: sha, - }); - const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - if (s.state === 'success') counts.success += 1; - else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; - else if (s.state === 'pending') counts.pending += 1; - }); - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } catch { - checks = null; - } - } - } - - // Permission check (best-effort) - let canMerge = false; - try { - const auth = getGitHubAuth(); - const username = auth?.user?.login; - if (username) { - const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({ - owner: searchRepo.owner, - repo: searchRepo.repo, - username, - }); - const level = perm?.data?.permission; - canMerge = level === 'admin' || level === 'maintain' || level === 'write'; - } - } catch { - canMerge = false; - } - - const isMerged = Boolean(prData.merged || prData.merged_at); - const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); - - return res.json({ - connected: true, - repo: searchRepo, - branch, - pr: { - number: prData.number, - title: prData.title, - body: prData.body || '', - url: prData.html_url, - state: mergedState, - draft: Boolean(prData.draft), - base: prData.base?.ref, - head: prData.head?.ref, - headSha: prData.head?.sha, - mergeable: prData.mergeable, - mergeableState: prData.mergeable_state, - }, - checks, - canMerge, - defaultBranch: resolvedStatus.defaultBranch ?? null, - resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null, - }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - if (isGitHubResourceUnavailable(error)) { - return res.json({ - connected: true, - repo: null, - branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '', - pr: null, - checks: null, - canMerge: false, - defaultBranch: null, - resolvedRemoteName: null, - }); - } - console.error('Failed to load GitHub PR status:', error); - return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' }); - } - }); - - app.post('/api/github/pr/create', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; - const head = typeof req.body?.head === 'string' ? req.body.head.trim() : ''; - const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : ''; - const body = typeof req.body?.body === 'string' ? req.body.body : undefined; - const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined; - // remote = target repo (where PR is created, e.g., 'upstream' for forks) - const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin'; - // headRemote = source repo (where head branch lives, e.g., 'origin' for forks) - const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : ''; - if (!directory || !title || !head || !requestedBase) { - return res.status(400).json({ error: 'directory, title, head, base are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - const normalizeBranchRef = (value, remoteNames = new Set()) => { - if (!value) { - return value; - } - let normalized = value.trim(); - if (normalized.startsWith('refs/heads/')) { - normalized = normalized.substring('refs/heads/'.length); - } - if (normalized.startsWith('heads/')) { - normalized = normalized.substring('heads/'.length); - } - if (normalized.startsWith('remotes/')) { - normalized = normalized.substring('remotes/'.length); - } - - const slashIndex = normalized.indexOf('/'); - if (slashIndex > 0) { - const maybeRemote = normalized.slice(0, slashIndex); - if (remoteNames.has(maybeRemote)) { - const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim(); - if (withoutRemotePrefix) { - normalized = withoutRemotePrefix; - } - } - } - - return normalized; - }; - - // Determine the source remote for the head branch - // Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin - let sourceRemote = headRemote; - const { getStatus, getRemotes } = await import('./lib/git/index.js'); - - // If no explicit headRemote, check the branch's tracking info - if (!sourceRemote) { - const status = await getStatus(directory).catch(() => null); - if (status?.tracking) { - // tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main" - const trackingRemote = status.tracking.split('/')[0]; - if (trackingRemote) { - sourceRemote = trackingRemote; - } - } - } - - // Fallback: if targeting non-origin and no tracking info, try 'origin' - if (!sourceRemote && remote !== 'origin') { - sourceRemote = 'origin'; - } - - const remoteNames = new Set([remote]); - const remotes = await getRemotes(directory).catch(() => []); - for (const item of remotes) { - if (item?.name) { - remoteNames.add(item.name); - } - } - if (sourceRemote) { - remoteNames.add(sourceRemote); - } - - const base = normalizeBranchRef(requestedBase, remoteNames); - if (!base) { - return res.status(400).json({ error: 'Invalid base branch name' }); - } - - // For fork workflows: we need to determine the correct head reference - let headRef = head; - - if (sourceRemote && sourceRemote !== remote) { - // The branch is on a different remote than the target - this is a cross-repo PR - const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote); - if (headRepo) { - // Always use owner:branch format for cross-repo PRs - // GitHub API requires this when head is from a different repo/fork - if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { - headRef = `${headRepo.owner}:${head}`; - } - } - } - - // For cross-repo PRs, verify the branch exists on the head repo first - if (headRef.includes(':')) { - const [headOwner] = headRef.split(':'); - const headRepoName = sourceRemote - ? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo - : repo.repo; - - if (headRepoName) { - try { - await octokit.rest.repos.getBranch({ - owner: headOwner, - repo: headRepoName, - branch: head, - }); - } catch (branchError) { - if (branchError?.status === 404) { - return res.status(400).json({ - error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`, - }); - } - // For other errors, continue - let the PR create attempt handle it - } - } - } - - const created = await octokit.rest.pulls.create({ - owner: repo.owner, - repo: repo.repo, - title, - head: headRef, - base, - ...(typeof body === 'string' ? { body } : {}), - ...(typeof draft === 'boolean' ? { draft } : {}), - }); - - const pr = created?.data; - if (!pr) { - return res.status(500).json({ error: 'Failed to create PR' }); - } - - return res.json({ - number: pr.number, - title: pr.title, - body: pr.body || '', - url: pr.html_url, - state: pr.state === 'closed' ? 'closed' : 'open', - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - }); - } catch (error) { - console.error('Failed to create GitHub PR:', error); - - // Check for head validation error (common with fork PRs) - const errorMessage = error.message || ''; - const isHeadValidationError = - errorMessage.includes('Validation Failed') && - errorMessage.includes('"field":"head"') && - errorMessage.includes('"code":"invalid"'); - - if (isHeadValidationError) { - return res.status(400).json({ - error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.' - }); - } - - return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' }); - } - }); - - app.post('/api/github/pr/update', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; - const body = typeof req.body?.body === 'string' ? req.body.body : undefined; - if (!directory || !number || !title) { - return res.status(400).json({ error: 'directory, number, title are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - let updated; - try { - updated = await octokit.rest.pulls.update({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - title, - ...(typeof body === 'string' ? { body } : {}), - }); - } catch (error) { - if (error?.status === 401) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to edit this PR' }); - } - if (error?.status === 404) { - return res.status(404).json({ error: 'PR not found in this repository' }); - } - if (error?.status === 422) { - const apiMessage = error?.response?.data?.message; - const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0 - ? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code) - : null; - const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload'; - return res.status(422).json({ error: message }); - } - throw error; - } - - const pr = updated?.data; - if (!pr) { - return res.status(500).json({ error: 'Failed to update PR' }); - } - - return res.json({ - number: pr.number, - title: pr.title, - body: pr.body || '', - url: pr.html_url, - state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'), - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - }); - } catch (error) { - console.error('Failed to update GitHub PR:', error); - return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' }); - } - }); - - app.post('/api/github/pr/merge', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - const method = typeof req.body?.method === 'string' ? req.body.method : 'merge'; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - try { - const result = await octokit.rest.pulls.merge({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - merge_method: method, - }); - return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message }); - } catch (error) { - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to merge this PR' }); - } - if (error?.status === 405 || error?.status === 409) { - return res.json({ merged: false, message: error?.message || 'PR not mergeable' }); - } - throw error; - } - } catch (error) { - console.error('Failed to merge GitHub PR:', error); - return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' }); - } - }); - - app.post('/api/github/pr/ready', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); - const nodeId = pr?.data?.node_id; - if (!nodeId) { - return res.status(500).json({ error: 'Failed to resolve PR node id' }); - } - - if (pr?.data?.draft === false) { - return res.json({ ready: true }); - } - - try { - await octokit.graphql( - `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`, - { pullRequestId: nodeId } - ); - } catch (error) { - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to mark PR ready' }); - } - throw error; - } - - return res.json({ ready: true }); - } catch (error) { - console.error('Failed to mark PR ready:', error); - return res.status(500).json({ error: error.message || 'Failed to mark PR ready' }); - } - }); - - // ================= GitHub Issue APIs ================= - - app.get('/api/github/issues/list', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; - if (!directory) { - return res.status(400).json({ error: 'directory is required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, issues: [] }); - } - - const list = await octokit.rest.issues.listForRepo({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - const hasMore = /rel="next"/.test(link); - const issues = (Array.isArray(list?.data) ? list.data : []) - .filter((item) => !item?.pull_request) - .map((item) => ({ - number: item.number, - title: item.title, - url: item.html_url, - state: item.state === 'closed' ? 'closed' : 'open', - author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null, - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => { - if (typeof label === 'string') return null; - const name = typeof label?.name === 'string' ? label.name : ''; - if (!name) return null; - return { name, color: typeof label?.color === 'string' ? label.color : undefined }; - }) - .filter(Boolean) - : [], - })); - - return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); - } catch (error) { - console.error('Failed to list GitHub issues:', error); - return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); - } - }); - - app.get('/api/github/issues/get', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, issue: null }); - } - - const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number }); - const issue = result?.data; - if (!issue || issue.pull_request) { - return res.status(400).json({ error: 'Not a GitHub issue' }); - } - - return res.json({ - connected: true, - repo, - issue: { - number: issue.number, - title: issue.title, - url: issue.html_url, - state: issue.state === 'closed' ? 'closed' : 'open', - body: issue.body || '', - createdAt: issue.created_at, - updatedAt: issue.updated_at, - author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null, - assignees: Array.isArray(issue.assignees) - ? issue.assignees - .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null)) - .filter(Boolean) - : [], - labels: Array.isArray(issue.labels) - ? issue.labels - .map((label) => { - if (typeof label === 'string') return null; - const name = typeof label?.name === 'string' ? label.name : ''; - if (!name) return null; - return { name, color: typeof label?.color === 'string' ? label.color : undefined }; - }) - .filter(Boolean) - : [], - }, - }); - } catch (error) { - console.error('Failed to fetch GitHub issue:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' }); - } - }); - - app.get('/api/github/issues/comments', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, comments: [] }); - } - - const result = await octokit.rest.issues.listComments({ - owner: repo.owner, - repo: repo.repo, - issue_number: number, - per_page: 100, - }); - const comments = (Array.isArray(result?.data) ? result.data : []) - .map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - return res.json({ connected: true, repo, comments }); - } catch (error) { - console.error('Failed to fetch GitHub issue comments:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' }); - } - }); - - // ================= GitHub Pull Request Context APIs ================= - - app.get('/api/github/pulls/list', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; - if (!directory) { - return res.status(400).json({ error: 'directory is required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, prs: [] }); - } - - const list = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); - - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - const hasMore = /rel="next"/.test(link); - - const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => { - const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'); - const headRepo = pr.head?.repo - ? { - owner: pr.head.repo.owner?.login, - repo: pr.head.repo.name, - url: pr.head.repo.html_url, - cloneUrl: pr.head.repo.clone_url, - sshUrl: pr.head.repo.ssh_url, - } - : null; - return { - number: pr.number, - title: pr.title, - url: pr.html_url, - state: mergedState, - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null, - headLabel: pr.head?.label, - headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url - ? headRepo - : null, - }; - }); - - return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - console.error('Failed to list GitHub PRs:', error); - return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); - } - }); - - app.get('/api/github/pulls/context', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; - const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true'; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, pr: null }); - } - - const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); - const prData = prResp?.data; - if (!prData) { - return res.status(404).json({ error: 'PR not found' }); - } - - const headRepo = prData.head?.repo - ? { - owner: prData.head.repo.owner?.login, - repo: prData.head.repo.name, - url: prData.head.repo.html_url, - cloneUrl: prData.head.repo.clone_url, - sshUrl: prData.head.repo.ssh_url, - } - : null; - - const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); - const pr = { - number: prData.number, - title: prData.title, - url: prData.html_url, - state: mergedState, - draft: Boolean(prData.draft), - base: prData.base?.ref, - head: prData.head?.ref, - headSha: prData.head?.sha, - mergeable: prData.mergeable, - mergeableState: prData.mergeable_state, - author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null, - headLabel: prData.head?.label, - headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null, - body: prData.body || '', - createdAt: prData.created_at, - updatedAt: prData.updated_at, - }; - - const issueCommentsResp = await octokit.rest.issues.listComments({ - owner: repo.owner, - repo: repo.repo, - issue_number: number, - per_page: 100, - }); - const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - per_page: 100, - }); - const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - path: comment.path, - line: typeof comment.line === 'number' ? comment.line : null, - position: typeof comment.position === 'number' ? comment.position : null, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - const filesResp = await octokit.rest.pulls.listFiles({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - per_page: 100, - }); - const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({ - filename: f.filename, - status: f.status, - additions: f.additions, - deletions: f.deletions, - changes: f.changes, - patch: f.patch, - })); - - // checks summary (same logic as status endpoint) - let checks = null; - let checkRunsOut = undefined; - const sha = prData.head?.sha; - if (sha) { - try { - const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); - const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; - if (checkRuns.length > 0) { - const parsedJobs = new Map(); - const parsedAnnotations = new Map(); - if (includeCheckDetails) { - // Prefetch actions jobs per runId. - const runIds = new Set(); - const jobIds = new Map(); - for (const run of checkRuns) { - const details = typeof run.details_url === 'string' ? run.details_url : ''; - const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); - if (match) { - const runId = Number(match[1]); - const jobId = match[2] ? Number(match[2]) : null; - if (Number.isFinite(runId) && runId > 0) { - runIds.add(runId); - if (jobId && Number.isFinite(jobId) && jobId > 0) { - jobIds.set(details, { runId, jobId }); - } else { - jobIds.set(details, { runId, jobId: null }); - } - } - } - } - - for (const runId of runIds) { - try { - const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({ - owner: repo.owner, - repo: repo.repo, - run_id: runId, - per_page: 100, - }); - const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : []; - parsedJobs.set(runId, jobs); - } catch { - parsedJobs.set(runId, []); - } - } - - for (const run of checkRuns) { - const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : ''; - const shouldLoadAnnotations = Boolean( - run?.id - && runConclusion - && !['success', 'neutral', 'skipped'].includes(runConclusion) - ); - if (!shouldLoadAnnotations) { - continue; - } - - const checkRunId = Number(run.id); - if (!Number.isFinite(checkRunId) || checkRunId <= 0) { - continue; - } - - const annotations = []; - for (let page = 1; page <= 3; page += 1) { - try { - const annotationsResp = await octokit.rest.checks.listAnnotations({ - owner: repo.owner, - repo: repo.repo, - check_run_id: checkRunId, - per_page: 50, - page, - }); - const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : []; - annotations.push(...chunk); - if (chunk.length < 50) { - break; - } - } catch { - break; - } - } - - if (annotations.length > 0) { - parsedAnnotations.set(checkRunId, annotations); - } - } - } - - checkRunsOut = checkRuns.map((run) => { - const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined; - let job = undefined; - if (includeCheckDetails && detailsUrl) { - const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); - const runId = match ? Number(match[1]) : null; - const jobId = match && match[2] ? Number(match[2]) : null; - if (runId && Number.isFinite(runId)) { - const jobs = parsedJobs.get(runId) || []; - const matched = jobId - ? jobs.find((j) => j.id === jobId) - : null; - const picked = matched || jobs.find((j) => j.name === run.name) || null; - if (picked) { - job = { - runId, - jobId: picked.id, - url: picked.html_url, - name: picked.name, - conclusion: picked.conclusion, - steps: Array.isArray(picked.steps) - ? picked.steps.map((s) => ({ - name: s.name, - status: s.status, - conclusion: s.conclusion, - number: s.number, - startedAt: s.started_at || undefined, - completedAt: s.completed_at || undefined, - })) - : undefined, - }; - } else { - job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl }; - } - } - } - - return { - id: run.id, - name: run.name, - app: run.app - ? { - name: run.app.name || undefined, - slug: run.app.slug || undefined, - } - : undefined, - status: run.status, - conclusion: run.conclusion, - detailsUrl, - output: run.output - ? { - title: run.output.title || undefined, - summary: run.output.summary || undefined, - text: run.output.text || undefined, - } - : undefined, - ...(job ? { job } : {}), - ...(run.id && parsedAnnotations.has(run.id) - ? { - annotations: parsedAnnotations.get(run.id).map((a) => ({ - path: a.path || undefined, - startLine: typeof a.start_line === 'number' ? a.start_line : undefined, - endLine: typeof a.end_line === 'number' ? a.end_line : undefined, - level: a.annotation_level || undefined, - message: a.message || '', - title: a.title || undefined, - rawDetails: a.raw_details || undefined, - })).filter((a) => a.message), - } - : {}), - }; - }); - const counts = { success: 0, failure: 0, pending: 0 }; - for (const run of checkRuns) { - const status = run?.status; - const conclusion = run?.conclusion; - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - continue; - } - if (!conclusion) { - counts.pending += 1; - continue; - } - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { - counts.success += 1; - } else { - counts.failure += 1; - } - } - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } - } catch { - // ignore and fall back - } - if (!checks) { - try { - const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha }); - const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - if (s.state === 'success') counts.success += 1; - else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; - else if (s.state === 'pending') counts.pending += 1; - }); - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } catch { - checks = null; - } - } - } - - let diff = undefined; - if (includeDiff) { - const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { - owner: repo.owner, - repo: repo.repo, - pull_number: number, - headers: { accept: 'application/vnd.github.v3.diff' }, - }); - diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined; - } - - return res.json({ - connected: true, - repo, - pr, - issueComments, - reviewComments, - files, - ...(diff ? { diff } : {}), - checks, - ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}), - }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - console.error('Failed to load GitHub PR context:', error); - return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' }); - } - }); - - app.get('/api/provider/:providerId/source', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requestedDirectory = headerDirectory || queryDirectory || null; - - let directory = null; - const resolved = await resolveProjectDirectory(req); - if (resolved.directory) { - directory = resolved.directory; - } else if (requestedDirectory) { - return res.status(400).json({ error: resolved.error }); - } - - const sources = getProviderSources(providerId, directory); - const { getProviderAuth } = await getAuthLibrary(); - const auth = getProviderAuth(providerId); - sources.sources.auth.exists = Boolean(auth); - - res.json({ - providerId, - sources: sources.sources, - }); - } catch (error) { - console.error('Failed to get provider sources:', error); - res.status(500).json({ error: error.message || 'Failed to get provider sources' }); - } - }); - - app.get('/api/quota/providers', async (_req, res) => { - try { - const { listConfiguredQuotaProviders } = await getQuotaProviders(); - const providers = listConfiguredQuotaProviders(); - res.json({ providers }); - } catch (error) { - console.error('Failed to list quota providers:', error); - res.status(500).json({ error: error.message || 'Failed to list quota providers' }); - } - }); - - app.get('/api/quota/:providerId', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - const { fetchQuotaForProvider } = await getQuotaProviders(); - const result = await fetchQuotaForProvider(providerId); - res.json(result); - } catch (error) { - console.error('Failed to fetch quota:', error); - res.status(500).json({ error: error.message || 'Failed to fetch quota' }); - } - }); - - app.delete('/api/provider/:providerId/auth', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - - const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth'; - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requestedDirectory = headerDirectory || queryDirectory || null; - let directory = null; - - if (scope === 'project' || requestedDirectory) { - const resolved = await resolveProjectDirectory(req); - if (!resolved.directory) { - return res.status(400).json({ error: resolved.error }); - } - directory = resolved.directory; - } else { - const resolved = await resolveProjectDirectory(req); - if (resolved.directory) { - directory = resolved.directory; - } - } - - let removed = false; - if (scope === 'auth') { - const { removeProviderAuth } = await getAuthLibrary(); - removed = removeProviderAuth(providerId); - } else if (scope === 'user' || scope === 'project' || scope === 'custom') { - removed = removeProviderConfig(providerId, directory, scope); - } else if (scope === 'all') { - const { removeProviderAuth } = await getAuthLibrary(); - const authRemoved = removeProviderAuth(providerId); - const userRemoved = removeProviderConfig(providerId, directory, 'user'); - const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false; - const customRemoved = removeProviderConfig(providerId, directory, 'custom'); - removed = authRemoved || userRemoved || projectRemoved || customRemoved; - } else { - return res.status(400).json({ error: 'Invalid scope' }); - } - - if (removed) { - await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`); - } - - res.json({ - success: true, - removed, - requiresReload: removed, - message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', - reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } catch (error) { - console.error('Failed to disconnect provider:', error); - res.status(500).json({ error: error.message || 'Failed to disconnect provider' }); - } - }); - - let gitLibraries = null; - const getGitLibraries = async () => { - if (!gitLibraries) { - gitLibraries = await import('./lib/git/index.js'); - } - return gitLibraries; - }; - - app.get('/api/git/identities', async (req, res) => { - const { getProfiles } = await getGitLibraries(); - try { - const profiles = getProfiles(); - res.json(profiles); - } catch (error) { - console.error('Failed to list git identity profiles:', error); - res.status(500).json({ error: 'Failed to list git identity profiles' }); - } - }); - - app.post('/api/git/identities', async (req, res) => { - const { createProfile } = await getGitLibraries(); - try { - const profile = createProfile(req.body); - console.log(`Created git identity profile: ${profile.name} (${profile.id})`); - res.json(profile); - } catch (error) { - console.error('Failed to create git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to create git identity profile' }); - } - }); - - app.put('/api/git/identities/:id', async (req, res) => { - const { updateProfile } = await getGitLibraries(); - try { - const profile = updateProfile(req.params.id, req.body); - console.log(`Updated git identity profile: ${profile.name} (${profile.id})`); - res.json(profile); - } catch (error) { - console.error('Failed to update git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to update git identity profile' }); - } - }); - - app.delete('/api/git/identities/:id', async (req, res) => { - const { deleteProfile } = await getGitLibraries(); - try { - deleteProfile(req.params.id); - console.log(`Deleted git identity profile: ${req.params.id}`); - res.json({ success: true }); - } catch (error) { - console.error('Failed to delete git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to delete git identity profile' }); - } - }); - - app.get('/api/git/global-identity', async (req, res) => { - const { getGlobalIdentity } = await getGitLibraries(); - try { - const identity = await getGlobalIdentity(); - res.json(identity); - } catch (error) { - console.error('Failed to get global git identity:', error); - res.status(500).json({ error: 'Failed to get global git identity' }); - } - }); - - app.get('/api/git/discover-credentials', async (req, res) => { - try { - const { discoverGitCredentials } = await import('./lib/git/index.js'); - const credentials = discoverGitCredentials(); - res.json(credentials); - } catch (error) { - console.error('Failed to discover git credentials:', error); - res.status(500).json({ error: 'Failed to discover git credentials' }); - } - }); - - app.get('/api/git/check', async (req, res) => { - const { isGitRepository } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const isRepo = await isGitRepository(directory); - res.json({ isGitRepository: isRepo }); - } catch (error) { - console.error('Failed to check git repository:', error); - res.status(500).json({ error: 'Failed to check git repository' }); - } - }); - - app.get('/api/git/remote-url', async (req, res) => { - const { getRemoteUrl } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - const remote = req.query.remote || 'origin'; - - const url = await getRemoteUrl(directory, remote); - res.json({ url }); - } catch (error) { - console.error('Failed to get remote url:', error); - res.status(500).json({ error: 'Failed to get remote url' }); - } - }); - - app.get('/api/git/current-identity', async (req, res) => { - const { getCurrentIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const identity = await getCurrentIdentity(directory); - res.json(identity); - } catch (error) { - console.error('Failed to get current git identity:', error); - res.status(500).json({ error: 'Failed to get current git identity' }); - } - }); - - app.get('/api/git/has-local-identity', async (req, res) => { - const { hasLocalIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const hasLocal = await hasLocalIdentity(directory); - res.json({ hasLocalIdentity: hasLocal }); - } catch (error) { - console.error('Failed to check local git identity:', error); - res.status(500).json({ error: 'Failed to check local git identity' }); - } - }); - - app.post('/api/git/set-identity', async (req, res) => { - const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { profileId } = req.body; - if (!profileId) { - return res.status(400).json({ error: 'profileId is required' }); - } - - let profile = null; - - if (profileId === 'global') { - const globalIdentity = await getGlobalIdentity(); - if (!globalIdentity?.userName || !globalIdentity?.userEmail) { - return res.status(404).json({ error: 'Global identity is not configured' }); - } - profile = { - id: 'global', - name: 'Global Identity', - userName: globalIdentity.userName, - userEmail: globalIdentity.userEmail, - sshKey: globalIdentity.sshCommand - ? globalIdentity.sshCommand.replace('ssh -i ', '') - : null, - }; - } else { - profile = getProfile(profileId); - if (!profile) { - return res.status(404).json({ error: 'Profile not found' }); - } - } - - await setLocalIdentity(directory, profile); - res.json({ success: true, profile }); - } catch (error) { - console.error('Failed to set git identity:', error); - res.status(500).json({ error: error.message || 'Failed to set git identity' }); - } - }); - - app.get('/api/git/status', async (req, res) => { - const { getStatus, isGitRepository } = await getGitLibraries(); - - const extractGitErrorText = (error) => { - const message = typeof error?.message === 'string' ? error.message : ''; - const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; - const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; - return [message, stderr, stdout] - .map((value) => String(value || '').trim()) - .filter(Boolean) - .join('\n'); - }; - - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const isRepo = await isGitRepository(directory); - if (!isRepo) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); - } - - const status = await getStatus(directory); - res.json(status); - } catch (error) { - const errorText = extractGitErrorText(error); - if (/not a git repository/i.test(errorText)) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); - } - console.error('Failed to get git status:', error); - res.status(500).json({ error: error.message || 'Failed to get git status' }); - } - }); - - app.get('/api/git/diff', async (req, res) => { - const { getDiff } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const path = req.query.path; - if (!path || typeof path !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - const staged = req.query.staged === 'true'; - const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; - - const diff = await getDiff(directory, { - path, - staged, - contextLines: Number.isFinite(context) ? context : 3, - }); - - res.json({ diff }); - } catch (error) { - console.error('Failed to get git diff:', error); - res.status(500).json({ error: error.message || 'Failed to get git diff' }); - } - }); - - app.get('/api/git/file-diff', async (req, res) => { - const { getFileDiff } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const pathParam = req.query.path; - if (!pathParam || typeof pathParam !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - const staged = req.query.staged === 'true'; - - const result = await getFileDiff(directory, { - path: pathParam, - staged, - }); - - res.json({ - original: result.original, - modified: result.modified, - path: result.path, - isBinary: Boolean(result.isBinary), - }); - } catch (error) { - console.error('Failed to get git file diff:', error); - res.status(500).json({ error: error.message || 'Failed to get git file diff' }); - } - }); - - app.post('/api/git/revert', async (req, res) => { - const { revertFile } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { path } = req.body || {}; - if (!path || typeof path !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - await revertFile(directory, path); - res.json({ success: true }); - } catch (error) { - console.error('Failed to revert git file:', error); - res.status(500).json({ error: error.message || 'Failed to revert git file' }); - } - }); - - app.post('/api/git/pull', async (req, res) => { - const { pull } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await pull(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to pull:', error); - res.status(500).json({ error: error.message || 'Failed to pull from remote' }); - } - }); - - app.post('/api/git/push', async (req, res) => { - const { push } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await push(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to push:', error); - res.status(500).json({ error: error.message || 'Failed to push to remote' }); - } - }); - - app.post('/api/git/fetch', async (req, res) => { - const { fetch: gitFetch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await gitFetch(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to fetch:', error); - res.status(500).json({ error: error.message || 'Failed to fetch from remote' }); - } - }); - - app.get('/api/git/remotes', async (req, res) => { - const { getRemotes } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const remotes = await getRemotes(directory); - res.json(remotes); - } catch (error) { - console.error('Failed to get remotes:', error); - res.status(500).json({ error: error.message || 'Failed to get remotes' }); - } - }); - - app.delete('/api/git/remotes', async (req, res) => { - const { removeRemote } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const remote = String(req.body?.remote || '').trim(); - if (!remote) { - return res.status(400).json({ error: 'remote is required' }); - } - - const result = await removeRemote(directory, { remote }); - res.json(result); - } catch (error) { - console.error('Failed to remove remote:', error); - res.status(500).json({ error: error.message || 'Failed to remove remote' }); - } - }); - - app.post('/api/git/rebase', async (req, res) => { - const { rebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await rebase(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to rebase:', error); - res.status(500).json({ error: error.message || 'Failed to rebase' }); - } - }); - - app.post('/api/git/rebase/abort', async (req, res) => { - const { abortRebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await abortRebase(directory); - res.json(result); - } catch (error) { - console.error('Failed to abort rebase:', error); - res.status(500).json({ error: error.message || 'Failed to abort rebase' }); - } - }); - - app.post('/api/git/merge', async (req, res) => { - const { merge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await merge(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to merge:', error); - res.status(500).json({ error: error.message || 'Failed to merge' }); - } - }); - - app.post('/api/git/merge/abort', async (req, res) => { - const { abortMerge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await abortMerge(directory); - res.json(result); - } catch (error) { - console.error('Failed to abort merge:', error); - res.status(500).json({ error: error.message || 'Failed to abort merge' }); - } - }); - - app.post('/api/git/rebase/continue', async (req, res) => { - const { continueRebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await continueRebase(directory); - res.json(result); - } catch (error) { - console.error('Failed to continue rebase:', error); - res.status(500).json({ error: error.message || 'Failed to continue rebase' }); - } - }); - - app.post('/api/git/merge/continue', async (req, res) => { - const { continueMerge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await continueMerge(directory); - res.json(result); - } catch (error) { - console.error('Failed to continue merge:', error); - res.status(500).json({ error: error.message || 'Failed to continue merge' }); - } - }); - - app.get('/api/git/conflict-details', async (req, res) => { - const { getConflictDetails } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await getConflictDetails(directory); - res.json(result); - } catch (error) { - console.error('Failed to get conflict details:', error); - res.status(500).json({ error: error.message || 'Failed to get conflict details' }); - } - }); - - app.post('/api/git/stash', async (req, res) => { - const { stash } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stash(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to stash:', error); - res.status(500).json({ error: error.message || 'Failed to stash' }); - } - }); - - app.post('/api/git/stash/pop', async (req, res) => { - const { stashPop } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stashPop(directory); - res.json(result); - } catch (error) { - console.error('Failed to pop stash:', error); - res.status(500).json({ error: error.message || 'Failed to pop stash' }); - } - }); - - app.post('/api/git/commit', async (req, res) => { - const { commit } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { message, addAll, files } = req.body; - if (!message) { - return res.status(400).json({ error: 'message is required' }); - } - - const result = await commit(directory, message, { - addAll, - files, - }); - res.json(result); - } catch (error) { - console.error('Failed to commit:', error); - res.status(500).json({ error: error.message || 'Failed to create commit' }); - } - }); - - app.get('/api/git/branches', async (req, res) => { - const { getBranches } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const branches = await getBranches(directory); - res.json(branches); - } catch (error) { - console.error('Failed to get branches:', error); - res.status(500).json({ error: error.message || 'Failed to get branches' }); - } - }); - - app.post('/api/git/branches', async (req, res) => { - const { createBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { name, startPoint } = req.body; - if (!name) { - return res.status(400).json({ error: 'name is required' }); - } - - const result = await createBranch(directory, name, { startPoint }); - res.json(result); - } catch (error) { - console.error('Failed to create branch:', error); - res.status(500).json({ error: error.message || 'Failed to create branch' }); - } - }); - - app.delete('/api/git/branches', async (req, res) => { - const { deleteBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch, force } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await deleteBranch(directory, branch, { force }); - res.json(result); - } catch (error) { - console.error('Failed to delete branch:', error); - res.status(500).json({ error: error.message || 'Failed to delete branch' }); - } - }); - - - app.put('/api/git/branches/rename', async (req, res) => { - const { renameBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { oldName, newName } = req.body; - if (!oldName) { - return res.status(400).json({ error: 'oldName is required' }); - } - if (!newName) { - return res.status(400).json({ error: 'newName is required' }); - } - - const result = await renameBranch(directory, oldName, newName); - res.json(result); - } catch (error) { - console.error('Failed to rename branch:', error); - res.status(500).json({ error: error.message || 'Failed to rename branch' }); - } - }); - app.delete('/api/git/remote-branches', async (req, res) => { - const { deleteRemoteBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch, remote } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await deleteRemoteBranch(directory, { branch, remote }); - res.json(result); - } catch (error) { - console.error('Failed to delete remote branch:', error); - res.status(500).json({ error: error.message || 'Failed to delete remote branch' }); - } - }); - - app.post('/api/git/checkout', async (req, res) => { - const { checkoutBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await checkoutBranch(directory, branch); - res.json(result); - } catch (error) { - console.error('Failed to checkout branch:', error); - res.status(500).json({ error: error.message || 'Failed to checkout branch' }); - } - }); - - app.get('/api/git/worktrees', async (req, res) => { - const { getWorktrees } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const worktrees = await getWorktrees(directory); - res.json(worktrees); - } catch (error) { - // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries) - // when the directory isn't a git repo or uses shell shorthand like "~/". - console.warn('Failed to get worktrees, returning empty list:', error?.message || error); - res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable'); - res.json([]); - } - }); - - app.post('/api/git/worktrees/validate', async (req, res) => { - const { validateWorktreeCreate } = await getGitLibraries(); - if (typeof validateWorktreeCreate !== 'function') { - return res.status(501).json({ error: 'Worktree validation is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await validateWorktreeCreate(directory, req.body || {}); - res.json(result); - } catch (error) { - console.error('Failed to validate worktree creation:', error); - res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); - } - }); - - app.post('/api/git/worktrees', async (req, res) => { - const { createWorktree } = await getGitLibraries(); - if (typeof createWorktree !== 'function') { - return res.status(501).json({ error: 'Worktree creation is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const created = await createWorktree(directory, req.body || {}); - res.json(created); - } catch (error) { - console.error('Failed to create worktree:', error); - res.status(500).json({ error: error.message || 'Failed to create worktree' }); - } - }); - - app.post('/api/git/worktrees/preview', async (req, res) => { - const { previewWorktreeCreate } = await getGitLibraries(); - if (typeof previewWorktreeCreate !== 'function') { - return res.status(501).json({ error: 'Worktree preview is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const preview = await previewWorktreeCreate(directory, req.body || {}); - res.json(preview); - } catch (error) { - console.error('Failed to preview worktree:', error); - res.status(500).json({ error: error.message || 'Failed to preview worktree' }); - } - }); - - app.get('/api/git/worktrees/bootstrap-status', async (req, res) => { - const { getWorktreeBootstrapStatus } = await getGitLibraries(); - if (typeof getWorktreeBootstrapStatus !== 'function') { - return res.status(501).json({ error: 'Worktree bootstrap status is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const status = await getWorktreeBootstrapStatus(directory); - res.json(status); - } catch (error) { - console.error('Failed to get worktree bootstrap status:', error); - res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' }); - } - }); - - app.delete('/api/git/worktrees', async (req, res) => { - const { removeWorktree } = await getGitLibraries(); - if (typeof removeWorktree !== 'function') { - return res.status(501).json({ error: 'Worktree removal is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; - if (!worktreeDirectory) { - return res.status(400).json({ error: 'worktree directory is required' }); - } - - const result = await removeWorktree(directory, { - directory: worktreeDirectory, - deleteLocalBranch: req.body?.deleteLocalBranch === true, - }); - res.json({ success: Boolean(result) }); - } catch (error) { - console.error('Failed to remove worktree:', error); - res.status(500).json({ error: error.message || 'Failed to remove worktree' }); - } - }); - - app.get('/api/git/worktree-type', async (req, res) => { - const { isLinkedWorktree } = await getGitLibraries(); - try { - const { directory } = req.query; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - const linked = await isLinkedWorktree(directory); - res.json({ linked }); - } catch (error) { - console.error('Failed to determine worktree type:', error); - res.status(500).json({ error: error.message || 'Failed to determine worktree type' }); - } - }); - - app.get('/api/git/log', async (req, res) => { - const { getLog } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { maxCount, from, to, file } = req.query; - const log = await getLog(directory, { - maxCount: maxCount ? parseInt(maxCount) : undefined, - from, - to, - file - }); - res.json(log); - } catch (error) { - console.error('Failed to get log:', error); - res.status(500).json({ error: error.message || 'Failed to get commit log' }); - } - }); - - app.get('/api/git/commit-files', async (req, res) => { - const { getCommitFiles } = await getGitLibraries(); - try { - const { directory, hash } = req.query; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - if (!hash) { - return res.status(400).json({ error: 'hash parameter is required' }); - } - - const result = await getCommitFiles(directory, hash); - res.json(result); - } catch (error) { - console.error('Failed to get commit files:', error); - res.status(500).json({ error: error.message || 'Failed to get commit files' }); - } - }); - - app.get('/api/fs/home', (req, res) => { - try { - const home = os.homedir(); - if (!home || typeof home !== 'string' || home.length === 0) { - return res.status(500).json({ error: 'Failed to resolve home directory' }); - } - res.json({ home }); - } catch (error) { - console.error('Failed to resolve home directory:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' }); - } - }); - - app.post('/api/fs/mkdir', async (req, res) => { - try { - const { path: dirPath, allowOutsideWorkspace } = req.body ?? {}; - - if (typeof dirPath !== 'string' || !dirPath.trim()) { - return res.status(400).json({ error: 'Path is required' }); - } - - let resolvedPath = ''; - - if (allowOutsideWorkspace) { - resolvedPath = path.resolve(normalizeDirectoryPath(dirPath)); - } else { - const resolved = await resolveWorkspacePathFromContext(req, dirPath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - resolvedPath = resolved.resolved; - } - - await fsPromises.mkdir(resolvedPath, { recursive: true }); - - res.json({ success: true, path: resolvedPath }); - } catch (error) { - console.error('Failed to create directory:', error); - res.status(500).json({ error: error.message || 'Failed to create directory' }); - } - }); - - // Read file contents - app.get('/api/fs/read', async (req, res) => { - const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; - if (!filePath) { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - const [canonicalPath, canonicalBase] = await Promise.all([ - fsPromises.realpath(resolved.resolved), - fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), - ]); - - if (!isPathWithinRoot(canonicalPath, canonicalBase)) { - return res.status(403).json({ error: 'Access to file denied' }); - } - - const stats = await fsPromises.stat(canonicalPath); - if (!stats.isFile()) { - return res.status(400).json({ error: 'Specified path is not a file' }); - } - - const content = await fsPromises.readFile(canonicalPath, 'utf8'); - res.type('text/plain').send(content); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); - } - }); - - // Read file as raw bytes (images, etc.) - app.get('/api/fs/raw', async (req, res) => { - const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; - if (!filePath) { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - const [canonicalPath, canonicalBase] = await Promise.all([ - fsPromises.realpath(resolved.resolved), - fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), - ]); - - if (!isPathWithinRoot(canonicalPath, canonicalBase)) { - return res.status(403).json({ error: 'Access to file denied' }); - } - - const stats = await fsPromises.stat(canonicalPath); - if (!stats.isFile()) { - return res.status(400).json({ error: 'Specified path is not a file' }); - } - - const ext = path.extname(canonicalPath).toLowerCase(); - const mimeMap = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.ico': 'image/x-icon', - '.bmp': 'image/bmp', - '.avif': 'image/avif', - }; - const mimeType = mimeMap[ext] || 'application/octet-stream'; - - const content = await fsPromises.readFile(canonicalPath); - res.setHeader('Cache-Control', 'no-store'); - res.type(mimeType).send(content); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read raw file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); - } - }); - - // Write file contents - app.post('/api/fs/write', async (req, res) => { - const { path: filePath, content } = req.body || {}; - if (!filePath || typeof filePath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - if (typeof content !== 'string') { - return res.status(400).json({ error: 'Content is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - // Ensure parent directory exists - await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); - await fsPromises.writeFile(resolved.resolved, content, 'utf8'); - res.json({ success: true, path: resolved.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to write file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to write file' }); - } - }); - - // Delete file or directory - app.post('/api/fs/delete', async (req, res) => { - const { path: targetPath } = req.body || {}; - if (!targetPath || typeof targetPath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, targetPath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - await fsPromises.rm(resolved.resolved, { recursive: true, force: true }); - - res.json({ success: true, path: resolved.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File or directory not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to delete path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to delete path' }); - } - }); - - // Rename/Move file or directory - app.post('/api/fs/rename', async (req, res) => { - const { oldPath, newPath } = req.body || {}; - if (!oldPath || typeof oldPath !== 'string') { - return res.status(400).json({ error: 'oldPath is required' }); - } - if (!newPath || typeof newPath !== 'string') { - return res.status(400).json({ error: 'newPath is required' }); - } - - try { - const resolvedOld = await resolveWorkspacePathFromContext(req, oldPath); - if (!resolvedOld.ok) { - return res.status(400).json({ error: resolvedOld.error }); - } - const resolvedNew = await resolveWorkspacePathFromContext(req, newPath); - if (!resolvedNew.ok) { - return res.status(400).json({ error: resolvedNew.error }); - } - - if (resolvedOld.base !== resolvedNew.base) { - return res.status(400).json({ error: 'Source and destination must share the same workspace root' }); - } - - await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved); - - res.json({ success: true, path: resolvedNew.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'Source path not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to rename path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to rename path' }); - } - }); - - // Reveal a file or folder in the system file manager (Finder on macOS, Explorer on Windows, etc.) - app.post('/api/fs/reveal', async (req, res) => { - const { path: targetPath } = req.body || {}; - if (!targetPath || typeof targetPath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = path.resolve(targetPath.trim()); - - // Verify path exists - await fsPromises.access(resolved); - - const platform = process.platform; - if (platform === 'darwin') { - // macOS: open -R selects the file in Finder; open opens a folder - const stat = await fsPromises.stat(resolved); - if (stat.isDirectory()) { - spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } else { - spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } - } else if (platform === 'win32') { - // Windows: explorer /select, highlights the file - spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } else { - // Linux: xdg-open opens the parent directory - const stat = await fsPromises.stat(resolved); - const dir = stat.isDirectory() ? resolved : path.dirname(resolved); - spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } - - res.json({ success: true, path: resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'Path not found' }); - } - console.error('Failed to reveal path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' }); - } - }); - - // Execute shell commands in a directory (for worktree setup) - // NOTE: This route supports background execution to avoid tying up browser connections. - const execJobs = new Map(); - const EXEC_JOB_TTL_MS = 30 * 60 * 1000; - const COMMAND_TIMEOUT_MS = (() => { - const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS); - if (Number.isFinite(raw) && raw > 0) return raw; - // `bun install` (common worktree setup cmd) often takes >60s. - return 5 * 60 * 1000; - })(); - - const pruneExecJobs = () => { - const now = Date.now(); - for (const [jobId, job] of execJobs.entries()) { - if (!job || typeof job !== 'object') { - execJobs.delete(jobId); - continue; - } - const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0; - if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) { - execJobs.delete(jobId); - } - } - }; - - const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => { - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - let timedOut = false; - - const envPath = buildAugmentedPath(); - const execEnv = { ...process.env, PATH: envPath }; - - const child = spawn(shell, [shellFlag, command], { - cwd: resolvedCwd, - env: execEnv, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const timeout = setTimeout(() => { - timedOut = true; - try { - child.kill('SIGKILL'); - } catch { - // ignore - } - }, COMMAND_TIMEOUT_MS); - - child.stdout?.on('data', (chunk) => { - stdout += chunk.toString(); - }); - - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); - - child.on('error', (error) => { - clearTimeout(timeout); - resolve({ - command, - success: false, - exitCode: undefined, - stdout: stdout.trim(), - stderr: stderr.trim(), - error: (error && error.message) || 'Command execution failed', - }); - }); - - child.on('close', (code, signal) => { - clearTimeout(timeout); - const exitCode = typeof code === 'number' ? code : undefined; - const base = { - command, - success: exitCode === 0 && !timedOut, - exitCode, - stdout: stdout.trim(), - stderr: stderr.trim(), - }; - - if (timedOut) { - resolve({ - ...base, - success: false, - error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''), - }); - return; - } - - resolve(base); - }); - }); - }; - - const runExecJob = async (job) => { - job.status = 'running'; - job.updatedAt = Date.now(); - - const results = []; - - for (const command of job.commands) { - if (typeof command !== 'string' || !command.trim()) { - results.push({ command, success: false, error: 'Invalid command' }); - continue; - } - - try { - const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd); - results.push(result); - } catch (error) { - results.push({ - command, - success: false, - error: (error && error.message) || 'Command execution failed', - }); - } - - job.results = results; - job.updatedAt = Date.now(); - } - - job.results = results; - job.success = results.every((r) => r.success); - job.status = 'done'; - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - }; - - app.post('/api/fs/exec', async (req, res) => { - const { commands, cwd, background } = req.body || {}; - if (!Array.isArray(commands) || commands.length === 0) { - return res.status(400).json({ error: 'Commands array is required' }); - } - if (!cwd || typeof cwd !== 'string') { - return res.status(400).json({ error: 'Working directory (cwd) is required' }); - } - - pruneExecJobs(); - - try { - const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd)); - const stats = await fsPromises.stat(resolvedCwd); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Specified cwd is not a directory' }); - } - - const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - - const jobId = crypto.randomUUID(); - const job = { - jobId, - status: 'queued', - success: null, - commands, - resolvedCwd, - shell, - shellFlag, - results: [], - startedAt: Date.now(), - finishedAt: null, - updatedAt: Date.now(), - }; - - execJobs.set(jobId, job); - - const isBackground = background === true; - if (isBackground) { - void runExecJob(job).catch((error) => { - job.status = 'done'; - job.success = false; - job.results = Array.isArray(job.results) ? job.results : []; - job.results.push({ - command: '', - success: false, - error: (error && error.message) || 'Command execution failed', - }); - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - }); - - return res.status(202).json({ - jobId, - status: 'running', - }); - } - - await runExecJob(job); - res.json({ - jobId, - status: job.status, - success: job.success === true, - results: job.results, - }); - } catch (error) { - console.error('Failed to execute commands:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' }); - } - }); - - app.get('/api/fs/exec/:jobId', (req, res) => { - const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : ''; - if (!jobId) { - return res.status(400).json({ error: 'Job id is required' }); - } - - pruneExecJobs(); - - const job = execJobs.get(jobId); - if (!job) { - return res.status(404).json({ error: 'Job not found' }); - } - - job.updatedAt = Date.now(); - - return res.json({ - jobId: job.jobId, - status: job.status, - success: job.success === true, - results: Array.isArray(job.results) ? job.results : [], - }); - }); - - app.post('/api/opencode/directory', async (req, res) => { - try { - const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : ''; - if (!requestedPath) { - return res.status(400).json({ error: 'Path is required' }); - } - - const validated = await validateDirectoryPath(requestedPath); - if (!validated.ok) { - return res.status(400).json({ error: validated.error }); - } - - const resolvedPath = validated.directory; - const currentSettings = await readSettingsFromDisk(); - const existingProjects = sanitizeProjects(currentSettings.projects) || []; - const existing = existingProjects.find((project) => project.path === resolvedPath) || null; - - const nextProjects = existing - ? existingProjects - : [ - ...existingProjects, - { - id: crypto.randomUUID(), - path: resolvedPath, - addedAt: Date.now(), - lastOpenedAt: Date.now(), - }, - ]; - - const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id; - - const updated = await persistSettings({ - projects: nextProjects, - activeProjectId, - lastDirectory: resolvedPath, - }); - - res.json({ - success: true, - restarted: false, - path: resolvedPath, - settings: updated, - }); - } catch (error) { - console.error('Failed to update OpenCode working directory:', error); - res.status(500).json({ error: error.message || 'Failed to update working directory' }); - } - }); - - app.get('/api/fs/list', async (req, res) => { - const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 - ? req.query.path.trim() - : os.homedir(); - const respectGitignore = req.query.respectGitignore === 'true'; - let resolvedPath = ''; - - const isPlansDirectory = (value) => { - if (!value || typeof value !== 'string') return false; - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans'); - }; - - try { - resolvedPath = path.resolve(normalizeDirectoryPath(rawPath)); - - const stats = await fsPromises.stat(resolvedPath); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Specified path is not a directory' }); - } - - const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true }); - - // Get gitignored paths if requested - let ignoredPaths = new Set(); - if (respectGitignore) { - try { - // Get all entry paths to check (relative to resolvedPath for git check-ignore) - const pathsToCheck = dirents.map((d) => d.name); - - if (pathsToCheck.length > 0) { - try { - // Use git check-ignore with paths as arguments - // Pass paths directly as arguments (works for reasonable directory sizes) - const result = await new Promise((resolve) => { - const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { - cwd: resolvedPath, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - child.stdout.on('data', (data) => { stdout += data.toString(); }); - child.on('close', () => resolve(stdout)); - child.on('error', () => resolve('')); - }); - - result.split('\n').filter(Boolean).forEach((name) => { - const fullPath = path.join(resolvedPath, name.trim()); - ignoredPaths.add(fullPath); - }); - } catch { - // git check-ignore fails if not a git repo, continue without filtering - } - } - } catch { - // If git is not available, continue without gitignore filtering - } - } - - const entries = await Promise.all( - dirents.map(async (dirent) => { - const entryPath = path.join(resolvedPath, dirent.name); - - // Skip gitignored entries - if (respectGitignore && ignoredPaths.has(entryPath)) { - return null; - } - - let isDirectory = dirent.isDirectory(); - const isSymbolicLink = dirent.isSymbolicLink(); - - if (!isDirectory && isSymbolicLink) { - try { - const linkStats = await fsPromises.stat(entryPath); - isDirectory = linkStats.isDirectory(); - } catch { - isDirectory = false; - } - } - - return { - name: dirent.name, - path: entryPath, - isDirectory, - isFile: dirent.isFile(), - isSymbolicLink - }; - }) - ); - - res.json({ - path: resolvedPath, - entries: entries.filter(Boolean) - }); - } catch (error) { - const err = error; - const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; - const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath)); - if (!isPlansPath) { - console.error('Failed to list directory:', error); - } - if (code === 'ENOENT') { - // Return empty result for plans directory (expected to not exist until first use) - if (isPlansPath) { - return res.json({ path: resolvedPath || rawPath, entries: [] }); - } - return res.status(404).json({ error: 'Directory not found' }); - } - if (code === 'EACCES') { - return res.status(403).json({ error: 'Access to directory denied' }); - } - res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); - } - }); - - let ptyProviderPromise = null; - const getPtyProvider = async () => { - if (ptyProviderPromise) { - return ptyProviderPromise; - } - - ptyProviderPromise = (async () => { - const isBunRuntime = typeof globalThis.Bun !== 'undefined'; - - if (isBunRuntime) { - try { - const bunPty = await import('bun-pty'); - console.log('Using bun-pty for terminal sessions'); - return { spawn: bunPty.spawn, backend: 'bun-pty' }; - } catch (error) { - console.warn('bun-pty unavailable, falling back to node-pty'); - } - } - - try { - const nodePty = await import('node-pty'); - console.log('Using node-pty for terminal sessions'); - return { spawn: nodePty.spawn, backend: 'node-pty' }; - } catch (error) { - console.error('Failed to load node-pty:', error && error.message ? error.message : error); - if (isBunRuntime) { - throw new Error('No PTY backend available. Install bun-pty or node-pty.'); - } - throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)'); - } - })(); - - return ptyProviderPromise; - }; - - const getTerminalShellCandidates = () => { - if (process.platform === 'win32') { - const windowsCandidates = [ - process.env.OPENCHAMBER_TERMINAL_SHELL, - process.env.SHELL, - process.env.ComSpec, - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), - 'pwsh.exe', - 'powershell.exe', - 'cmd.exe', - ].filter(Boolean); - - const resolved = []; - const seen = new Set(); - for (const candidateRaw of windowsCandidates) { - const candidate = String(candidateRaw).trim(); - if (!candidate) continue; - - const lookedUp = candidate.includes('\\') || candidate.includes('/') - ? candidate - : searchPathFor(candidate); - const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); - if (!executable || seen.has(executable)) continue; - seen.add(executable); - resolved.push(executable); - } - return resolved; - } - - const unixCandidates = [ - process.env.OPENCHAMBER_TERMINAL_SHELL, - process.env.SHELL, - '/bin/zsh', - '/bin/bash', - '/bin/sh', - 'zsh', - 'bash', - 'sh', - ].filter(Boolean); - - const resolved = []; - const seen = new Set(); - for (const candidateRaw of unixCandidates) { - const candidate = String(candidateRaw).trim(); - if (!candidate) continue; - - const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate); - const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); - if (!executable || seen.has(executable)) continue; - seen.add(executable); - resolved.push(executable); - } - - return resolved; - }; - - const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => { - const shellCandidates = getTerminalShellCandidates(); - if (shellCandidates.length === 0) { - throw new Error('No executable shell found for terminal session'); - } - - let lastError = null; - for (const shell of shellCandidates) { - try { - const ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: cols || 80, - rows: rows || 24, - cwd, - env: { - ...env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - }, - }); - - return { ptyProcess, shell }; - } catch (error) { - lastError = error; - console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error); - } - } - - const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed'; - throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`); - }; - - const terminalSessions = new Map(); - const MAX_TERMINAL_SESSIONS = 20; - const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; - const sanitizeTerminalEnv = (env) => { - const next = { ...env }; - delete next.BASH_XTRACEFD; - delete next.BASH_ENV; - delete next.ENV; - return next; - }; - const terminalInputCapabilities = { - input: { - preferred: 'ws', - transports: ['http', 'ws'], - ws: { - path: TERMINAL_INPUT_WS_PATH, - v: 1, - enc: 'text+json-bin-control', - }, - }, - }; - - const sendTerminalInputWsControl = (socket, payload) => { - if (!socket || socket.readyState !== 1) { - return; - } - - try { - socket.send(createTerminalInputWsControlFrame(payload), { binary: true }); - } catch { - } - }; - - terminalInputWsServer = new WebSocketServer({ - noServer: true, - maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - }); - - terminalInputWsServer.on('connection', (socket) => { - const connectionState = { - boundSessionId: null, - invalidFrames: 0, - rebindTimestamps: [], - lastActivityAt: Date.now(), - }; - - sendTerminalInputWsControl(socket, { t: 'ok', v: 1 }); - - const heartbeatInterval = setInterval(() => { - if (socket.readyState !== 1) { - return; - } - - try { - socket.ping(); - } catch { - } - }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS); - - socket.on('pong', () => { - connectionState.lastActivityAt = Date.now(); - }); - - socket.on('message', (message, isBinary) => { - connectionState.lastActivityAt = Date.now(); - - if (isBinary) { - const controlMessage = readTerminalInputWsControlFrame(message); - if (!controlMessage || typeof controlMessage.t !== 'string') { - connectionState.invalidFrames += 1; - sendTerminalInputWsControl(socket, { - t: 'e', - c: 'BAD_FRAME', - f: connectionState.invalidFrames >= 10, - }); - if (connectionState.invalidFrames >= 10) { - socket.close(1008, 'protocol violation'); - } - return; - } - - if (controlMessage.t === 'p') { - sendTerminalInputWsControl(socket, { t: 'po', v: 1 }); - return; - } - - if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') { - connectionState.invalidFrames += 1; - sendTerminalInputWsControl(socket, { - t: 'e', - c: 'BAD_FRAME', - f: connectionState.invalidFrames >= 10, - }); - if (connectionState.invalidFrames >= 10) { - socket.close(1008, 'protocol violation'); - } - return; - } - - const now = Date.now(); - connectionState.rebindTimestamps = pruneRebindTimestamps( - connectionState.rebindTimestamps, - now, - TERMINAL_INPUT_WS_REBIND_WINDOW_MS - ); - - if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) { - sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false }); - return; - } - - const nextSessionId = controlMessage.s.trim(); - const targetSession = terminalSessions.get(nextSessionId); - if (!targetSession) { - connectionState.boundSessionId = null; - sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); - return; - } - - connectionState.rebindTimestamps.push(now); - connectionState.boundSessionId = nextSessionId; - sendTerminalInputWsControl(socket, { t: 'bok', v: 1 }); - return; - } - - const payload = normalizeTerminalInputWsMessageToText(message); - if (payload.length === 0) { - return; - } - - if (!connectionState.boundSessionId) { - sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false }); - return; - } - - const session = terminalSessions.get(connectionState.boundSessionId); - if (!session) { - connectionState.boundSessionId = null; - sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); - return; - } - - try { - session.ptyProcess.write(payload); - session.lastActivity = Date.now(); - } catch { - sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false }); - } - }); - - socket.on('close', () => { - clearInterval(heartbeatInterval); - }); - - socket.on('error', (error) => { - void error; - }); - }); - - server.on('upgrade', (req, socket, head) => { - const pathname = parseRequestPathname(req.url); - if (pathname !== TERMINAL_INPUT_WS_PATH) { - return; - } - - const handleUpgrade = async () => { - try { - if (uiAuthController?.enabled) { - // Must be awaited: this call performs async token verification. - const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null); - if (!sessionToken) { - rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); - return; - } - - const originAllowed = await isRequestOriginAllowed(req); - if (!originAllowed) { - rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); - return; - } - } - - if (!terminalInputWsServer) { - rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); - return; - } - - terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => { - terminalInputWsServer.emit('connection', ws, req); - }); - } catch { - rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); - } - }; - - void handleUpgrade(); - }); - - setInterval(() => { - const now = Date.now(); - for (const [sessionId, session] of terminalSessions.entries()) { - if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) { - console.log(`Cleaning up idle terminal session: ${sessionId}`); - try { - session.ptyProcess.kill(); - } catch (error) { - - } - terminalSessions.delete(sessionId); - } - } - }, 5 * 60 * 1000); - - app.post('/api/terminal/create', async (req, res) => { - try { - if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) { - return res.status(429).json({ error: 'Maximum terminal sessions reached' }); - } - - const { cwd, cols, rows } = req.body; - if (!cwd) { - return res.status(400).json({ error: 'cwd is required' }); - } - - try { - await fs.promises.access(cwd); - } catch { - return res.status(400).json({ error: 'Invalid working directory' }); - } - - const sessionId = Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - - const envPath = buildAugmentedPath(); - const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); - - const pty = await getPtyProvider(); - const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { - cols, - rows, - cwd, - env: resolvedEnv, - }); - - const session = { - ptyProcess, - ptyBackend: pty.backend, - cwd, - lastActivity: Date.now(), - clients: new Set(), - }; - - terminalSessions.set(sessionId, session); - - ptyProcess.onExit(({ exitCode, signal }) => { - console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`); - terminalSessions.delete(sessionId); - }); - - console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`); - res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); - } catch (error) { - console.error('Failed to create terminal session:', error); - res.status(500).json({ error: error.message || 'Failed to create terminal session' }); - } - }); - - app.get('/api/terminal/:sessionId/stream', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - const clientId = Math.random().toString(36).substring(7); - session.clients.add(clientId); - session.lastActivity = Date.now(); - - const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun'; - const ptyBackend = session.ptyBackend || 'unknown'; - res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`); - - const heartbeatInterval = setInterval(() => { - try { - - res.write(': heartbeat\n\n'); - } catch (error) { - console.error(`Heartbeat failed for client ${clientId}:`, error); - clearInterval(heartbeatInterval); - } - }, 15000); - - const dataHandler = (data) => { - try { - session.lastActivity = Date.now(); - const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`); - if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') { - session.ptyProcess.pause(); - res.once('drain', () => { - if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') { - session.ptyProcess.resume(); - } - }); - } - } catch (error) { - console.error(`Error sending data to client ${clientId}:`, error); - cleanup(); - } - }; - - const exitHandler = ({ exitCode, signal }) => { - try { - res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`); - res.end(); - } catch (error) { - - } - cleanup(); - }; - - const dataDisposable = session.ptyProcess.onData(dataHandler); - const exitDisposable = session.ptyProcess.onExit(exitHandler); - - const cleanup = () => { - clearInterval(heartbeatInterval); - session.clients.delete(clientId); - - if (dataDisposable && typeof dataDisposable.dispose === 'function') { - dataDisposable.dispose(); - } - if (exitDisposable && typeof exitDisposable.dispose === 'function') { - exitDisposable.dispose(); - } - - try { - res.end(); - } catch (error) { - - } - - console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`); - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`); - }); - - app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - const data = typeof req.body === 'string' ? req.body : ''; - - try { - session.ptyProcess.write(data); - session.lastActivity = Date.now(); - res.json({ success: true }); - } catch (error) { - console.error('Failed to write to terminal:', error); - res.status(500).json({ error: error.message || 'Failed to write to terminal' }); - } - }); - - app.post('/api/terminal/:sessionId/resize', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - const { cols, rows } = req.body; - if (!cols || !rows) { - return res.status(400).json({ error: 'cols and rows are required' }); - } - - try { - session.ptyProcess.resize(cols, rows); - session.lastActivity = Date.now(); - res.json({ success: true, cols, rows }); - } catch (error) { - console.error('Failed to resize terminal:', error); - res.status(500).json({ error: error.message || 'Failed to resize terminal' }); - } - }); - - app.delete('/api/terminal/:sessionId', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - try { - session.ptyProcess.kill(); - terminalSessions.delete(sessionId); - console.log(`Closed terminal session: ${sessionId}`); - res.json({ success: true }); - } catch (error) { - console.error('Failed to close terminal:', error); - res.status(500).json({ error: error.message || 'Failed to close terminal' }); - } - }); - - app.post('/api/terminal/:sessionId/restart', async (req, res) => { - const { sessionId } = req.params; - const { cwd, cols, rows } = req.body; - - if (!cwd) { - return res.status(400).json({ error: 'cwd is required' }); - } - - const existingSession = terminalSessions.get(sessionId); - if (existingSession) { - try { - existingSession.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(sessionId); - } - - try { - try { - const stats = await fs.promises.stat(cwd); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Invalid working directory: not a directory' }); - } - } catch (error) { - return res.status(400).json({ error: 'Invalid working directory: not accessible' }); - } - - const newSessionId = Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - - const envPath = buildAugmentedPath(); - const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); - - const pty = await getPtyProvider(); - const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { - cols, - rows, - cwd, - env: resolvedEnv, - }); - - const session = { - ptyProcess, - ptyBackend: pty.backend, - cwd, - lastActivity: Date.now(), - clients: new Set(), - }; - - terminalSessions.set(newSessionId, session); - - ptyProcess.onExit(({ exitCode, signal }) => { - console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`); - terminalSessions.delete(newSessionId); - }); - - console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`); - res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); - } catch (error) { - console.error('Failed to restart terminal session:', error); - res.status(500).json({ error: error.message || 'Failed to restart terminal session' }); - } - }); - - app.post('/api/terminal/force-kill', (req, res) => { - const { sessionId, cwd } = req.body; - let killedCount = 0; - - if (sessionId) { - const session = terminalSessions.get(sessionId); - if (session) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(sessionId); - killedCount++; - } - } else if (cwd) { - for (const [id, session] of terminalSessions) { - if (session.cwd === cwd) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(id); - killedCount++; - } - } - } else { - for (const [id, session] of terminalSessions) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(id); - killedCount++; - } - } - - console.log(`Force killed ${killedCount} terminal session(s)`); - res.json({ success: true, killedCount }); - }); - - setupProxy(app); - scheduleOpenCodeApiDetection(); - void bootstrapOpenCodeAtStartup(); - - const distPath = (() => { - const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : ''; - if (env) { - return path.resolve(env); - } - return path.join(__dirname, '..', 'dist'); - })(); - - if (fs.existsSync(distPath)) { - console.log(`Serving static files from ${distPath}`); - app.use(express.static(distPath, { - setHeaders(res, filePath) { - // Service workers should never be long-cached; iOS is especially sensitive. - if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) { - res.setHeader('Cache-Control', 'no-store'); - } - }, - })); - - const recentPwaSessionsCache = new Map(); - - const getRecentPwaSessionShortcuts = async (req) => { - const now = Date.now(); - - const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null })); - const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string' - ? resolvedDirectoryResult.directory - : null; - - const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global'; - const cached = recentPwaSessionsCache.get(cacheKey); - if (cached && now - cached.at < 5000) { - return cached.data; - } - - const normalizeShortcutTitle = (value, fallback) => { - const normalized = normalizePwaAppName(value, fallback); - return normalized.length > 48 ? normalized.slice(0, 48) : normalized; - }; - - const toFiniteNumber = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; - }; - - const normalizeDirectory = (value) => { - if (typeof value !== 'string') { - return ''; - } - const trimmed = value.trim(); - if (!trimmed) { - return ''; - } - const normalized = trimmed.replace(/\\/g, '/'); - if (normalized === '/') { - return '/'; - } - return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; - }; - - const sessionUpdatedAt = (session) => { - const time = session && typeof session.time === 'object' ? session.time : null; - return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0; - }; - - const filterSessionsByDirectory = (sessions, directory) => { - const normalizedDirectory = normalizeDirectory(directory); - if (!normalizedDirectory) { - return sessions; - } - - const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`; - return sessions.filter((session) => { - const sessionDirectory = normalizeDirectory(session?.directory); - if (!sessionDirectory) { - return false; - } - return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix)); - }); - }; - - const listSessions = async (directory) => { - const query = (() => { - if (typeof directory !== 'string' || directory.length === 0) { - return ''; - } - const preparedDirectory = process.platform === 'win32' - ? directory.replace(/\//g, '\\') - : directory; - return `?directory=${encodeURIComponent(preparedDirectory)}`; - })(); - - const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(2500), - }); - - if (!response.ok) { - return []; - } - - const payload = await response.json().catch(() => null); - return Array.isArray(payload) ? payload : []; - }; - - try { - let payload = []; - - if (preferredDirectory) { - const scopedPayload = await listSessions(preferredDirectory); - const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory); - - if (filteredScopedPayload.length > 0) { - payload = filteredScopedPayload; - } else { - const globalPayload = await listSessions(null); - const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory); - payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload; - } - } else { - payload = await listSessions(null); - } - - const seen = new Set(); - const rows = []; - - for (const item of payload) { - if (!item || typeof item !== 'object') { - continue; - } - - const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : ''; - if (!id || seen.has(id)) { - continue; - } - - seen.add(id); - const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`); - const updatedAt = sessionUpdatedAt(item); - - rows.push({ id, title, updatedAt }); - } - - rows.sort((a, b) => b.updatedAt - a.updatedAt); - - const shortcuts = rows.slice(0, 3).map((session) => ({ - name: session.title, - short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title, - description: 'Open recent session', - url: `/?session=${encodeURIComponent(session.id)}`, - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], - })); - - recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts }); - return shortcuts; - } catch { - recentPwaSessionsCache.set(cacheKey, { at: now, data: [] }); - return []; - } - }; - - app.get('/manifest.webmanifest', async (req, res) => { - const hasQueryOverride = - typeof req.query?.pwa_name === 'string' - || typeof req.query?.app_name === 'string' - || typeof req.query?.appName === 'string'; - - let queryValueRaw = ''; - if (typeof req.query?.pwa_name === 'string') { - queryValueRaw = req.query.pwa_name; - } else if (typeof req.query?.app_name === 'string') { - queryValueRaw = req.query.app_name; - } else if (typeof req.query?.appName === 'string') { - queryValueRaw = req.query.appName; - } - - const queryOverrideName = normalizePwaAppName(queryValueRaw, ''); - - let storedName = ''; - try { - const settings = await readSettingsFromDiskMigrated(); - storedName = normalizePwaAppName(settings?.pwaAppName, ''); - } catch { - storedName = ''; - } - - const appName = hasQueryOverride - ? (queryOverrideName || DEFAULT_PWA_APP_NAME) - : (storedName || DEFAULT_PWA_APP_NAME); - - const shortName = appName.length > 30 ? appName.slice(0, 30) : appName; - const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req); - - const manifest = { - name: appName, - short_name: shortName, - description: 'Web interface companion for OpenCode AI coding agent', - id: '/', - start_url: '/', - scope: '/', - display: 'standalone', - background_color: '#151313', - theme_color: '#edb449', - orientation: 'any', - icons: [ - { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, - { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, - { src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, - { src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, - { src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' }, - { src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' }, - { src: '/favicon-32.png', sizes: '32x32', type: 'image/png' }, - { src: '/favicon-16.png', sizes: '16x16', type: 'image/png' }, - ], - shortcuts: [ - { - name: 'Appearance Settings', - short_name: 'Settings', - description: 'Open appearance settings', - url: '/?settings=appearance', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], - }, - ...recentSessionShortcuts, - ], - categories: ['developer', 'tools', 'productivity'], - lang: 'en', - }; - - res.setHeader('Cache-Control', 'no-store, must-revalidate'); - res.type('application/manifest+json'); - res.send(JSON.stringify(manifest)); - }); - - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { - res.sendFile(path.join(distPath, 'index.html')); - }); - } else { - console.warn(`Warning: ${distPath} not found, static files will not be served`); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { - res.status(404).send('Static files not found. Please build the application first.'); - }); - } - - let activePort = port; - - const bindHost = host - || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 - ? process.env.OPENCHAMBER_HOST.trim() - : '127.0.0.1'); - - await new Promise((resolve, reject) => { - const onError = (error) => { - server.off('error', onError); - reject(error); - }; - server.once('error', onError); - const onListening = async () => { - server.off('error', onError); - const addressInfo = server.address(); - activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; - - try { - process.send?.({ type: 'openchamber:ready', port: activePort }); - } catch { - // ignore - } - - const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]') - ? 'localhost' - : (bindHost.includes(':') ? `[${bindHost}]` : bindHost); - console.log(`OpenChamber server listening on ${bindHost}:${activePort}`); - console.log(`Health check: http://${displayHost}:${activePort}/health`); - console.log(`Web interface: http://${displayHost}:${activePort}`); - - if (startupTunnelRequest) { - const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK - ? 'Quick Tunnel' - : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL - ? 'Managed Local Tunnel' - : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel')); - console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`); - try { - const { publicUrl, mode } = await startTunnelWithNormalizedRequest({ - provider: startupTunnelRequest.provider, - mode: startupTunnelRequest.mode, - intent: startupTunnelRequest.intent, - hostname: startupTunnelRequest.hostname, - token: startupTunnelRequest.token, - configPath: startupTunnelRequest.configPath, - selectedPresetId: '', - selectedPresetName: '', - }); - if (publicUrl) { - tunnelAuthController.setActiveTunnel({ - tunnelId: crypto.randomUUID(), - publicUrl, - mode, - }); - const settings = await readSettingsFromDiskMigrated(); - const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); - const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); - const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; - if (onTunnelReady) { - onTunnelReady(publicUrl, connectUrl); - } else { - console.log(`\n🌐 Tunnel URL: ${connectUrl}`); - console.log('🔑 One-time connect link (expires after first use)\n'); - } - } else if (onTunnelReady) { - onTunnelReady(publicUrl, null); - } - } catch (error) { - console.error(`Failed to start tunnel: ${error.message}`); - console.log('Continuing without tunnel...'); - } - } - - resolve(); - }; - - server.listen(port, bindHost, onListening); - }); - - if (attachSignals && !signalsAttached) { - const handleSignal = async () => { - await gracefulShutdown(); - }; - process.on('SIGTERM', handleSignal); - process.on('SIGINT', handleSignal); - process.on('SIGQUIT', handleSignal); - signalsAttached = true; - syncToHmrState(); - } - - process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - }); - - process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error); - gracefulShutdown(); - }); + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + host, + port, + startupTunnelRequest, + onTunnelReady, + tunnelRuntimeContext, + attachSignals, + }); + terminalRuntime = startupPipelineResult.terminalRuntime; return { expressApp: app, httpServer: server, - getPort: () => activePort, + getPort: () => tunnelRuntimeContext.getActivePort(), getOpenCodePort: () => openCodePort, getTunnelUrl: () => tunnelService.getPublicUrl(), isReady: () => isOpenCodeReady, @@ -14387,27 +970,23 @@ async function main(options = {}) { }; } -const isCliExecution = process.argv[1] === __filename; +runCliEntryIfMain({ + process, + currentFilename: __filename, + parseServeCliOptions, + defaultPort: DEFAULT_PORT, + cloudflareProvider: TUNNEL_PROVIDER_CLOUDFLARE, + managedLocalMode: TUNNEL_MODE_MANAGED_LOCAL, + setExitOnShutdown: (value) => { + exitOnShutdown = value; + }, + startServer: main, +}); -if (isCliExecution) { - const cliOptions = parseArgs(); - exitOnShutdown = true; - main({ - port: cliOptions.port, - host: cliOptions.host, - tryCfTunnel: cliOptions.tryCfTunnel, - tunnelProvider: cliOptions.tunnelProvider, - tunnelMode: cliOptions.tunnelMode, - tunnelConfigPath: cliOptions.tunnelConfigPath, - tunnelToken: cliOptions.tunnelToken, - tunnelHostname: cliOptions.tunnelHostname, - attachSignals: true, - exitOnShutdown: true, - uiPassword: cliOptions.uiPassword - }).catch(error => { - console.error('Failed to start server:', error); - process.exit(1); - }); -} - -export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseArgs }; +export { + gracefulShutdown, + setupProxy, + restartOpenCode, + main as startWebUiServer, + parseServeCliOptions as parseArgs, +}; diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md new file mode 100644 index 00000000..dc87c8cd --- /dev/null +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -0,0 +1,36 @@ +# FS Module Documentation + +## Purpose +Own filesystem API behavior for the web server runtime, including workspace-bound file operations, directory listing, reveal, and background command execution jobs. + +## Entrypoints and structure +- `packages/web/server/lib/fs/routes.js`: route registration and runtime-owned state for `/api/fs/*` endpoints. +- `packages/web/server/lib/fs/search.js`: fuzzy filesystem search runtime used by non-FS routes (for example project icon discovery). + +## Public exports +- `registerFsRoutes(app, dependencies)` from `routes.js` + - Registers all filesystem routes: + - `GET /api/fs/home` + - `POST /api/fs/mkdir` + - `GET /api/fs/read` + - `GET /api/fs/raw` + - `POST /api/fs/write` + - `POST /api/fs/delete` + - `POST /api/fs/rename` + - `POST /api/fs/reveal` + - `POST /api/fs/exec` + - `GET /api/fs/exec/:jobId` + - `GET /api/fs/list` + - Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning. + - Enforces workspace boundary checks with active project + worktree fallback support. +- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js` + - Returns `{ searchFilesystemFiles(rootPath, options) }`. + - Supports fuzzy matching, hidden-file handling, and optional `git check-ignore` filtering. + +## Composition contract with `index.js` +- `index.js` provides composition-time dependencies only (platform primitives + callbacks such as `resolveProjectDirectory`, `normalizeDirectoryPath`, and `buildAugmentedPath`). +- `index.js` no longer owns FS route handlers or FS exec job state. + +## Notes for contributors +- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root. +- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js new file mode 100644 index 00000000..0eaaeeed --- /dev/null +++ b/packages/web/server/lib/fs/routes.js @@ -0,0 +1,760 @@ +const EXEC_JOB_TTL_MS = 30 * 60 * 1000; + +const createCommandTimeoutMs = () => { + const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS); + if (Number.isFinite(raw) && raw > 0) return raw; + return 5 * 60 * 1000; +}; + +const isPathWithinRoot = (resolvedPath, rootPath, path, os) => { + const resolvedRoot = path.resolve(rootPath || os.homedir()); + const relative = path.relative(resolvedRoot, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + return true; +}; + +const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => { + const normalized = normalizeDirectoryPath(targetPath); + if (!normalized || typeof normalized !== 'string') { + return { ok: false, error: 'Path is required' }; + } + + const resolved = path.resolve(normalized); + const resolvedBase = path.resolve(baseDirectory || os.homedir()); + + if (isPathWithinRoot(resolved, resolvedBase, path, os)) { + return { ok: true, base: resolvedBase, resolved }; + } + + if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) { + return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved }; + } + + return { ok: false, error: 'Path is outside of active workspace' }; +}; + +const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => { + const normalized = normalizeDirectoryPath(targetPath); + if (!normalized || typeof normalized !== 'string') { + return { ok: false, error: 'Path is required' }; + } + + const resolved = path.resolve(normalized); + const resolvedBase = path.resolve(baseDirectory || os.homedir()); + + try { + const { getWorktrees } = await import('../git/index.js'); + const worktrees = await getWorktrees(resolvedBase); + + for (const worktree of worktrees) { + const candidatePath = typeof worktree?.path === 'string' + ? worktree.path + : (typeof worktree?.worktree === 'string' ? worktree.worktree : ''); + const candidate = normalizeDirectoryPath(candidatePath); + if (!candidate) { + continue; + } + const candidateResolved = path.resolve(candidate); + if (isPathWithinRoot(resolved, candidateResolved, path, os)) { + return { ok: true, base: candidateResolved, resolved }; + } + } + } catch (error) { + console.warn('Failed to resolve worktree roots:', error); + } + + return { ok: false, error: 'Path is outside of active workspace' }; +}; + +const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => { + const resolvedProject = await resolveProjectDirectory(req); + if (!resolvedProject.directory) { + return { ok: false, error: resolvedProject.error || 'Active workspace is required' }; + } + + const resolved = resolveWorkspacePath({ + targetPath, + baseDirectory: resolvedProject.directory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (resolved.ok || resolved.error !== 'Path is outside of active workspace') { + return resolved; + } + + return resolveWorkspacePathFromWorktrees({ + targetPath, + baseDirectory: resolvedProject.directory, + path, + os, + normalizeDirectoryPath, + }); +}; + +const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => { + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const envPath = buildAugmentedPath(); + const execEnv = { ...process.env, PATH: envPath }; + + const child = spawn(shell, [shellFlag, command], { + cwd: resolvedCwd, + env: execEnv, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const timeout = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch { + } + }, commandTimeoutMs); + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ + command, + success: false, + exitCode: undefined, + stdout: stdout.trim(), + stderr: stderr.trim(), + error: (error && error.message) || 'Command execution failed', + }); + }); + + child.on('close', (code, signal) => { + clearTimeout(timeout); + const exitCode = typeof code === 'number' ? code : undefined; + const base = { + command, + success: exitCode === 0 && !timedOut, + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + }; + + if (timedOut) { + resolve({ + ...base, + success: false, + error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''), + }); + return; + } + + resolve(base); + }); + }); +}; + +export const registerFsRoutes = (app, dependencies) => { + const { + os, + path, + fsPromises, + spawn, + crypto, + normalizeDirectoryPath, + resolveProjectDirectory, + buildAugmentedPath, + resolveGitBinaryForSpawn, + openchamberUserConfigRoot, + } = dependencies; + + const execJobs = new Map(); + const commandTimeoutMs = createCommandTimeoutMs(); + + const pruneExecJobs = () => { + const now = Date.now(); + for (const [jobId, job] of execJobs.entries()) { + if (!job || typeof job !== 'object') { + execJobs.delete(jobId); + continue; + } + const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0; + if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) { + execJobs.delete(jobId); + } + } + }; + + const runExecJob = async (job) => { + job.status = 'running'; + job.updatedAt = Date.now(); + + const results = []; + for (const command of job.commands) { + if (typeof command !== 'string' || !command.trim()) { + results.push({ command, success: false, error: 'Invalid command' }); + continue; + } + + try { + const result = await runCommandInDirectory({ + shell: job.shell, + shellFlag: job.shellFlag, + command, + resolvedCwd: job.resolvedCwd, + spawn, + buildAugmentedPath, + commandTimeoutMs, + }); + results.push(result); + } catch (error) { + results.push({ + command, + success: false, + error: (error && error.message) || 'Command execution failed', + }); + } + + job.results = results; + job.updatedAt = Date.now(); + } + + job.results = results; + job.success = results.every((r) => r.success); + job.status = 'done'; + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + }; + + app.get('/api/fs/home', (_req, res) => { + try { + const home = os.homedir(); + if (!home || typeof home !== 'string' || home.length === 0) { + return res.status(500).json({ error: 'Failed to resolve home directory' }); + } + return res.json({ home }); + } catch (error) { + console.error('Failed to resolve home directory:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' }); + } + }); + + app.post('/api/fs/mkdir', async (req, res) => { + try { + const { path: dirPath, allowOutsideWorkspace } = req.body ?? {}; + if (typeof dirPath !== 'string' || !dirPath.trim()) { + return res.status(400).json({ error: 'Path is required' }); + } + + let resolvedPath = ''; + if (allowOutsideWorkspace) { + resolvedPath = path.resolve(normalizeDirectoryPath(dirPath)); + } else { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: dirPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + resolvedPath = resolved.resolved; + } + + await fsPromises.mkdir(resolvedPath, { recursive: true }); + return res.json({ success: true, path: resolvedPath }); + } catch (error) { + console.error('Failed to create directory:', error); + return res.status(500).json({ error: error.message || 'Failed to create directory' }); + } + }); + + app.get('/api/fs/read', async (req, res) => { + const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); + if (!stats.isFile()) { + return res.status(400).json({ error: 'Specified path is not a file' }); + } + + const content = await fsPromises.readFile(canonicalPath, 'utf8'); + return res.type('text/plain').send(content); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); + } + }); + + app.get('/api/fs/raw', async (req, res) => { + const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); + if (!stats.isFile()) { + return res.status(400).json({ error: 'Specified path is not a file' }); + } + + const ext = path.extname(canonicalPath).toLowerCase(); + const mimeMap = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + '.avif': 'image/avif', + }; + const mimeType = mimeMap[ext] || 'application/octet-stream'; + + const content = await fsPromises.readFile(canonicalPath); + res.setHeader('Cache-Control', 'no-store'); + return res.type(mimeType).send(content); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read raw file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); + } + }); + + app.post('/api/fs/write', async (req, res) => { + const { path: filePath, content } = req.body || {}; + if (!filePath || typeof filePath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + if (typeof content !== 'string') { + return res.status(400).json({ error: 'Content is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); + await fsPromises.writeFile(resolved.resolved, content, 'utf8'); + return res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to write file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to write file' }); + } + }); + + app.post('/api/fs/delete', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + await fsPromises.rm(resolved.resolved, { recursive: true, force: true }); + return res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File or directory not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to delete path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' }); + } + }); + + app.post('/api/fs/rename', async (req, res) => { + const { oldPath, newPath } = req.body || {}; + if (!oldPath || typeof oldPath !== 'string') { + return res.status(400).json({ error: 'oldPath is required' }); + } + if (!newPath || typeof newPath !== 'string') { + return res.status(400).json({ error: 'newPath is required' }); + } + + try { + const resolvedOld = await resolveWorkspacePathFromContext({ + req, + targetPath: oldPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolvedOld.ok) { + return res.status(400).json({ error: resolvedOld.error }); + } + + const resolvedNew = await resolveWorkspacePathFromContext({ + req, + targetPath: newPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolvedNew.ok) { + return res.status(400).json({ error: resolvedNew.error }); + } + + if (resolvedOld.base !== resolvedNew.base) { + return res.status(400).json({ error: 'Source and destination must share the same workspace root' }); + } + + await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved); + return res.json({ success: true, path: resolvedNew.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Source path not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to rename path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' }); + } + }); + + app.post('/api/fs/reveal', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = path.resolve(targetPath.trim()); + await fsPromises.access(resolved); + + const platform = process.platform; + if (platform === 'darwin') { + const stat = await fsPromises.stat(resolved); + if (stat.isDirectory()) { + spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } else { + spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } + } else if (platform === 'win32') { + spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } else { + const stat = await fsPromises.stat(resolved); + const dir = stat.isDirectory() ? resolved : path.dirname(resolved); + spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } + + return res.json({ success: true, path: resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Path not found' }); + } + console.error('Failed to reveal path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' }); + } + }); + + app.post('/api/fs/exec', async (req, res) => { + const { commands, cwd, background } = req.body || {}; + if (!Array.isArray(commands) || commands.length === 0) { + return res.status(400).json({ error: 'Commands array is required' }); + } + if (!cwd || typeof cwd !== 'string') { + return res.status(400).json({ error: 'Working directory (cwd) is required' }); + } + + pruneExecJobs(); + + try { + const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd)); + const stats = await fsPromises.stat(resolvedCwd); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified cwd is not a directory' }); + } + + const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + + const jobId = crypto.randomUUID(); + const job = { + jobId, + status: 'queued', + success: null, + commands, + resolvedCwd, + shell, + shellFlag, + results: [], + startedAt: Date.now(), + finishedAt: null, + updatedAt: Date.now(), + }; + + execJobs.set(jobId, job); + + const isBackground = background === true; + if (isBackground) { + void runExecJob(job).catch((error) => { + job.status = 'done'; + job.success = false; + job.results = Array.isArray(job.results) ? job.results : []; + job.results.push({ + command: '', + success: false, + error: (error && error.message) || 'Command execution failed', + }); + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + }); + + return res.status(202).json({ + jobId, + status: 'running', + }); + } + + await runExecJob(job); + return res.json({ + jobId, + status: job.status, + success: job.success === true, + results: job.results, + }); + } catch (error) { + console.error('Failed to execute commands:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' }); + } + }); + + app.get('/api/fs/exec/:jobId', (req, res) => { + const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : ''; + if (!jobId) { + return res.status(400).json({ error: 'Job id is required' }); + } + + pruneExecJobs(); + + const job = execJobs.get(jobId); + if (!job) { + return res.status(404).json({ error: 'Job not found' }); + } + + job.updatedAt = Date.now(); + return res.json({ + jobId: job.jobId, + status: job.status, + success: job.success === true, + results: Array.isArray(job.results) ? job.results : [], + }); + }); + + app.get('/api/fs/list', async (req, res) => { + const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 + ? req.query.path.trim() + : os.homedir(); + const respectGitignore = req.query.respectGitignore === 'true'; + let resolvedPath = ''; + + const isPlansDirectory = (value) => { + if (!value || typeof value !== 'string') return false; + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans'); + }; + + try { + resolvedPath = path.resolve(normalizeDirectoryPath(rawPath)); + + const stats = await fsPromises.stat(resolvedPath); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory' }); + } + + const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true }); + let ignoredPaths = new Set(); + if (respectGitignore) { + try { + const pathsToCheck = dirents.map((d) => d.name); + if (pathsToCheck.length > 0) { + try { + const result = await new Promise((resolve) => { + const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { + cwd: resolvedPath, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('close', () => resolve(stdout)); + child.on('error', () => resolve('')); + }); + + result.split('\n').filter(Boolean).forEach((name) => { + const fullPath = path.join(resolvedPath, name.trim()); + ignoredPaths.add(fullPath); + }); + } catch { + } + } + } catch { + } + } + + const entries = await Promise.all( + dirents.map(async (dirent) => { + const entryPath = path.join(resolvedPath, dirent.name); + if (respectGitignore && ignoredPaths.has(entryPath)) { + return null; + } + + let isDirectory = dirent.isDirectory(); + const isSymbolicLink = dirent.isSymbolicLink(); + + if (!isDirectory && isSymbolicLink) { + try { + const linkStats = await fsPromises.stat(entryPath); + isDirectory = linkStats.isDirectory(); + } catch { + isDirectory = false; + } + } + + return { + name: dirent.name, + path: entryPath, + isDirectory, + isFile: dirent.isFile(), + isSymbolicLink, + }; + }) + ); + + return res.json({ + path: resolvedPath, + entries: entries.filter(Boolean), + }); + } catch (error) { + const err = error; + const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath)); + if (!isPlansPath) { + console.error('Failed to list directory:', error); + } + if (code === 'ENOENT') { + if (isPlansPath) { + return res.json({ path: resolvedPath || rawPath, entries: [] }); + } + return res.status(404).json({ error: 'Directory not found' }); + } + if (code === 'EACCES') { + return res.status(403).json({ error: 'Access to directory denied' }); + } + return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); + } + }); +}; diff --git a/packages/web/server/lib/fs/search.js b/packages/web/server/lib/fs/search.js new file mode 100644 index 00000000..c770fdc6 --- /dev/null +++ b/packages/web/server/lib/fs/search.js @@ -0,0 +1,238 @@ +const FILE_SEARCH_MAX_CONCURRENCY = 5; +const FILE_SEARCH_EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + 'coverage', + 'tmp', + 'logs', +]); + +const normalizeRelativeSearchPath = (rootPath, targetPath, path) => { + const relative = path.relative(rootPath, targetPath) || path.basename(targetPath); + return relative.split(path.sep).join('/') || targetPath; +}; + +const shouldSkipSearchDirectory = (name, includeHidden) => { + if (!name) { + return false; + } + if (!includeHidden && name.startsWith('.')) { + return true; + } + return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); +}; + +const listDirectoryEntries = async (dirPath, fsPromises) => { + try { + return await fsPromises.readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } +}; + +const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => { + if (!normalizedQuery) return 0; + + const q = normalizedQuery; + const c = candidate.toLowerCase(); + if (c.includes(q)) { + const idx = c.indexOf(q); + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i += 1) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.min(gap, 10); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.floor(c.length / 3)); + return score; +}; + +export const createFsSearchRuntime = ({ fsPromises, path, spawn, resolveGitBinaryForSpawn }) => { + const searchFilesystemFiles = async (rootPath, options) => { + const { limit, query, includeHidden, respectGitignore } = options; + const includeHiddenEntries = Boolean(includeHidden); + const normalizedQuery = query.trim().toLowerCase(); + const matchAll = normalizedQuery.length === 0; + const queue = [rootPath]; + const visited = new Set([rootPath]); + const shouldRespectGitignore = respectGitignore !== false; + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates = []; + + while (queue.length > 0 && candidates.length < collectLimit) { + const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); + + const dirResults = await Promise.all( + batch.map(async (dir) => { + if (!shouldRespectGitignore) { + return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() }; + } + + try { + const dirents = await listDirectoryEntries(dir, fsPromises); + const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { dir, dirents, ignoredPaths: new Set() }; + } + + const result = await new Promise((resolve) => { + const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { + cwd: dir, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('close', () => resolve(stdout)); + child.on('error', () => resolve('')); + }); + + const ignoredNames = new Set( + String(result) + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + return { dir, dirents, ignoredPaths: ignoredNames }; + } catch { + return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() }; + } + }) + ); + + for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) { + for (const dirent of dirents) { + const entryName = dirent.name; + if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) { + continue; + } + + if (shouldRespectGitignore && ignoredPaths.has(entryName)) { + continue; + } + + const entryPath = path.join(currentDir, entryName); + + if (dirent.isDirectory()) { + if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) { + continue; + } + if (!visited.has(entryPath)) { + visited.add(entryPath); + queue.push(entryPath); + } + continue; + } + + if (!dirent.isFile()) { + continue; + } + + const relativePath = normalizeRelativeSearchPath(rootPath, entryPath, path); + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score: 0, + }); + } else { + const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score, + }); + } + } + + if (candidates.length >= collectLimit) { + queue.length = 0; + break; + } + } + + if (candidates.length >= collectLimit) { + break; + } + } + } + + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension, + })); + }; + + return { + searchFilesystemFiles, + }; +}; diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index d708e8e9..5222288a 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -6,6 +6,7 @@ This module provides Git repository operations for the web server runtime, inclu ## Entrypoints and structure - `packages/web/server/lib/git/`: Git module directory containing all Git-related functionality. - `index.js`: Public API entry point imported by `packages/web/server/index.js`. + - `routes.js`: Express route registration for `/api/git/*` endpoints. - `service.js`: Core Git operations (repository, branch, worktree, commit, merge/rebase, status/diff, log). - `credentials.js`: Git credentials management. - `identity-storage.js`: Git identity (user.name, user.email) storage. diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js new file mode 100644 index 00000000..964e4789 --- /dev/null +++ b/packages/web/server/lib/git/routes.js @@ -0,0 +1,867 @@ +export function registerGitRoutes(app) { + let gitLibraries = null; + const getGitLibraries = async () => { + if (!gitLibraries) { + gitLibraries = await import('./index.js'); + } + return gitLibraries; + }; + + app.get('/api/git/identities', async (req, res) => { + const { getProfiles } = await getGitLibraries(); + try { + const profiles = getProfiles(); + res.json(profiles); + } catch (error) { + console.error('Failed to list git identity profiles:', error); + res.status(500).json({ error: 'Failed to list git identity profiles' }); + } + }); + + app.post('/api/git/identities', async (req, res) => { + const { createProfile } = await getGitLibraries(); + try { + const profile = createProfile(req.body); + console.log(`Created git identity profile: ${profile.name} (${profile.id})`); + res.json(profile); + } catch (error) { + console.error('Failed to create git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to create git identity profile' }); + } + }); + + app.put('/api/git/identities/:id', async (req, res) => { + const { updateProfile } = await getGitLibraries(); + try { + const profile = updateProfile(req.params.id, req.body); + console.log(`Updated git identity profile: ${profile.name} (${profile.id})`); + res.json(profile); + } catch (error) { + console.error('Failed to update git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to update git identity profile' }); + } + }); + + app.delete('/api/git/identities/:id', async (req, res) => { + const { deleteProfile } = await getGitLibraries(); + try { + deleteProfile(req.params.id); + console.log(`Deleted git identity profile: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to delete git identity profile' }); + } + }); + + app.get('/api/git/global-identity', async (req, res) => { + const { getGlobalIdentity } = await getGitLibraries(); + try { + const identity = await getGlobalIdentity(); + res.json(identity); + } catch (error) { + console.error('Failed to get global git identity:', error); + res.status(500).json({ error: 'Failed to get global git identity' }); + } + }); + + app.get('/api/git/discover-credentials', async (req, res) => { + try { + const { discoverGitCredentials } = await import('./index.js'); + const credentials = discoverGitCredentials(); + res.json(credentials); + } catch (error) { + console.error('Failed to discover git credentials:', error); + res.status(500).json({ error: 'Failed to discover git credentials' }); + } + }); + + app.get('/api/git/check', async (req, res) => { + const { isGitRepository } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const isRepo = await isGitRepository(directory); + res.json({ isGitRepository: isRepo }); + } catch (error) { + console.error('Failed to check git repository:', error); + res.status(500).json({ error: 'Failed to check git repository' }); + } + }); + + app.get('/api/git/remote-url', async (req, res) => { + const { getRemoteUrl } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const remote = req.query.remote || 'origin'; + + const url = await getRemoteUrl(directory, remote); + res.json({ url }); + } catch (error) { + console.error('Failed to get remote url:', error); + res.status(500).json({ error: 'Failed to get remote url' }); + } + }); + + app.get('/api/git/current-identity', async (req, res) => { + const { getCurrentIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const identity = await getCurrentIdentity(directory); + res.json(identity); + } catch (error) { + console.error('Failed to get current git identity:', error); + res.status(500).json({ error: 'Failed to get current git identity' }); + } + }); + + app.get('/api/git/has-local-identity', async (req, res) => { + const { hasLocalIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const hasLocal = await hasLocalIdentity(directory); + res.json({ hasLocalIdentity: hasLocal }); + } catch (error) { + console.error('Failed to check local git identity:', error); + res.status(500).json({ error: 'Failed to check local git identity' }); + } + }); + + app.post('/api/git/set-identity', async (req, res) => { + const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { profileId } = req.body; + if (!profileId) { + return res.status(400).json({ error: 'profileId is required' }); + } + + let profile = null; + + if (profileId === 'global') { + const globalIdentity = await getGlobalIdentity(); + if (!globalIdentity?.userName || !globalIdentity?.userEmail) { + return res.status(404).json({ error: 'Global identity is not configured' }); + } + profile = { + id: 'global', + name: 'Global Identity', + userName: globalIdentity.userName, + userEmail: globalIdentity.userEmail, + sshKey: globalIdentity.sshCommand + ? globalIdentity.sshCommand.replace('ssh -i ', '') + : null, + }; + } else { + profile = getProfile(profileId); + if (!profile) { + return res.status(404).json({ error: 'Profile not found' }); + } + } + + await setLocalIdentity(directory, profile); + res.json({ success: true, profile }); + } catch (error) { + console.error('Failed to set git identity:', error); + res.status(500).json({ error: error.message || 'Failed to set git identity' }); + } + }); + + app.get('/api/git/status', async (req, res) => { + const { getStatus, isGitRepository } = await getGitLibraries(); + + const extractGitErrorText = (error) => { + const message = typeof error?.message === 'string' ? error.message : ''; + const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; + const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; + return [message, stderr, stdout] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join('\n'); + }; + + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const isRepo = await isGitRepository(directory); + if (!isRepo) { + return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + } + + const mode = req.query.mode === 'light' ? 'light' : undefined; + const status = await getStatus(directory, { mode }); + res.json(status); + } catch (error) { + const errorText = extractGitErrorText(error); + if (/not a git repository/i.test(errorText)) { + return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + } + console.error('Failed to get git status:', error); + res.status(500).json({ error: error.message || 'Failed to get git status' }); + } + }); + + app.get('/api/git/diff', async (req, res) => { + const { getDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const path = req.query.path; + if (!path || typeof path !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + const staged = req.query.staged === 'true'; + const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; + + const diff = await getDiff(directory, { + path, + staged, + contextLines: Number.isFinite(context) ? context : 3, + }); + + res.json({ diff }); + } catch (error) { + console.error('Failed to get git diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git diff' }); + } + }); + + app.get('/api/git/file-diff', async (req, res) => { + const { getFileDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const pathParam = req.query.path; + if (!pathParam || typeof pathParam !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + const staged = req.query.staged === 'true'; + + const result = await getFileDiff(directory, { + path: pathParam, + staged, + }); + + res.json({ + original: result.original, + modified: result.modified, + path: result.path, + isBinary: Boolean(result.isBinary), + }); + } catch (error) { + console.error('Failed to get git file diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git file diff' }); + } + }); + + app.post('/api/git/revert', async (req, res) => { + const { revertFile } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { path } = req.body || {}; + if (!path || typeof path !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + await revertFile(directory, path); + res.json({ success: true }); + } catch (error) { + console.error('Failed to revert git file:', error); + res.status(500).json({ error: error.message || 'Failed to revert git file' }); + } + }); + + app.post('/api/git/pull', async (req, res) => { + const { pull } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await pull(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to pull:', error); + res.status(500).json({ error: error.message || 'Failed to pull from remote' }); + } + }); + + app.post('/api/git/push', async (req, res) => { + const { push } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await push(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to push:', error); + res.status(500).json({ error: error.message || 'Failed to push to remote' }); + } + }); + + app.post('/api/git/fetch', async (req, res) => { + const { fetch: gitFetch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await gitFetch(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to fetch:', error); + res.status(500).json({ error: error.message || 'Failed to fetch from remote' }); + } + }); + + app.get('/api/git/remotes', async (req, res) => { + const { getRemotes } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const remotes = await getRemotes(directory); + res.json(remotes); + } catch (error) { + console.error('Failed to get remotes:', error); + res.status(500).json({ error: error.message || 'Failed to get remotes' }); + } + }); + + app.delete('/api/git/remotes', async (req, res) => { + const { removeRemote } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const remote = String(req.body?.remote || '').trim(); + if (!remote) { + return res.status(400).json({ error: 'remote is required' }); + } + + const result = await removeRemote(directory, { remote }); + res.json(result); + } catch (error) { + console.error('Failed to remove remote:', error); + res.status(500).json({ error: error.message || 'Failed to remove remote' }); + } + }); + + app.post('/api/git/rebase', async (req, res) => { + const { rebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await rebase(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to rebase:', error); + res.status(500).json({ error: error.message || 'Failed to rebase' }); + } + }); + + app.post('/api/git/rebase/abort', async (req, res) => { + const { abortRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort rebase:', error); + res.status(500).json({ error: error.message || 'Failed to abort rebase' }); + } + }); + + app.post('/api/git/merge', async (req, res) => { + const { merge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await merge(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to merge:', error); + res.status(500).json({ error: error.message || 'Failed to merge' }); + } + }); + + app.post('/api/git/merge/abort', async (req, res) => { + const { abortMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort merge:', error); + res.status(500).json({ error: error.message || 'Failed to abort merge' }); + } + }); + + app.post('/api/git/rebase/continue', async (req, res) => { + const { continueRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue rebase:', error); + res.status(500).json({ error: error.message || 'Failed to continue rebase' }); + } + }); + + app.post('/api/git/merge/continue', async (req, res) => { + const { continueMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue merge:', error); + res.status(500).json({ error: error.message || 'Failed to continue merge' }); + } + }); + + app.get('/api/git/conflict-details', async (req, res) => { + const { getConflictDetails } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await getConflictDetails(directory); + res.json(result); + } catch (error) { + console.error('Failed to get conflict details:', error); + res.status(500).json({ error: error.message || 'Failed to get conflict details' }); + } + }); + + app.post('/api/git/stash', async (req, res) => { + const { stash } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stash(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to stash:', error); + res.status(500).json({ error: error.message || 'Failed to stash' }); + } + }); + + app.post('/api/git/stash/pop', async (req, res) => { + const { stashPop } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stashPop(directory); + res.json(result); + } catch (error) { + console.error('Failed to pop stash:', error); + res.status(500).json({ error: error.message || 'Failed to pop stash' }); + } + }); + + app.post('/api/git/commit', async (req, res) => { + const { commit } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { message, addAll, files } = req.body; + if (!message) { + return res.status(400).json({ error: 'message is required' }); + } + + const result = await commit(directory, message, { + addAll, + files, + }); + res.json(result); + } catch (error) { + console.error('Failed to commit:', error); + res.status(500).json({ error: error.message || 'Failed to create commit' }); + } + }); + + app.get('/api/git/branches', async (req, res) => { + const { getBranches } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const branches = await getBranches(directory); + res.json(branches); + } catch (error) { + console.error('Failed to get branches:', error); + res.status(500).json({ error: error.message || 'Failed to get branches' }); + } + }); + + app.post('/api/git/branches', async (req, res) => { + const { createBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { name, startPoint } = req.body; + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const result = await createBranch(directory, name, { startPoint }); + res.json(result); + } catch (error) { + console.error('Failed to create branch:', error); + res.status(500).json({ error: error.message || 'Failed to create branch' }); + } + }); + + app.delete('/api/git/branches', async (req, res) => { + const { deleteBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch, force } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await deleteBranch(directory, branch, { force }); + res.json(result); + } catch (error) { + console.error('Failed to delete branch:', error); + res.status(500).json({ error: error.message || 'Failed to delete branch' }); + } + }); + + + app.put('/api/git/branches/rename', async (req, res) => { + const { renameBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { oldName, newName } = req.body; + if (!oldName) { + return res.status(400).json({ error: 'oldName is required' }); + } + if (!newName) { + return res.status(400).json({ error: 'newName is required' }); + } + + const result = await renameBranch(directory, oldName, newName); + res.json(result); + } catch (error) { + console.error('Failed to rename branch:', error); + res.status(500).json({ error: error.message || 'Failed to rename branch' }); + } + }); + app.delete('/api/git/remote-branches', async (req, res) => { + const { deleteRemoteBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch, remote } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await deleteRemoteBranch(directory, { branch, remote }); + res.json(result); + } catch (error) { + console.error('Failed to delete remote branch:', error); + res.status(500).json({ error: error.message || 'Failed to delete remote branch' }); + } + }); + + app.post('/api/git/checkout', async (req, res) => { + const { checkoutBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await checkoutBranch(directory, branch); + res.json(result); + } catch (error) { + console.error('Failed to checkout branch:', error); + res.status(500).json({ error: error.message || 'Failed to checkout branch' }); + } + }); + + app.get('/api/git/worktrees', async (req, res) => { + const { getWorktrees } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const worktrees = await getWorktrees(directory); + res.json(worktrees); + } catch (error) { + // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries) + // when the directory isn't a git repo or uses shell shorthand like "~/". + console.warn('Failed to get worktrees, returning empty list:', error?.message || error); + res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable'); + res.json([]); + } + }); + + app.post('/api/git/worktrees/validate', async (req, res) => { + const { validateWorktreeCreate } = await getGitLibraries(); + if (typeof validateWorktreeCreate !== 'function') { + return res.status(501).json({ error: 'Worktree validation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await validateWorktreeCreate(directory, req.body || {}); + res.json(result); + } catch (error) { + console.error('Failed to validate worktree creation:', error); + res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); + } + }); + + app.post('/api/git/worktrees', async (req, res) => { + const { createWorktree } = await getGitLibraries(); + if (typeof createWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree creation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const created = await createWorktree(directory, req.body || {}); + res.json(created); + } catch (error) { + console.error('Failed to create worktree:', error); + res.status(500).json({ error: error.message || 'Failed to create worktree' }); + } + }); + + app.post('/api/git/worktrees/preview', async (req, res) => { + const { previewWorktreeCreate } = await getGitLibraries(); + if (typeof previewWorktreeCreate !== 'function') { + return res.status(501).json({ error: 'Worktree preview is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const preview = await previewWorktreeCreate(directory, req.body || {}); + res.json(preview); + } catch (error) { + console.error('Failed to preview worktree:', error); + res.status(500).json({ error: error.message || 'Failed to preview worktree' }); + } + }); + + app.get('/api/git/worktrees/bootstrap-status', async (req, res) => { + const { getWorktreeBootstrapStatus } = await getGitLibraries(); + if (typeof getWorktreeBootstrapStatus !== 'function') { + return res.status(501).json({ error: 'Worktree bootstrap status is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const status = await getWorktreeBootstrapStatus(directory); + res.json(status); + } catch (error) { + console.error('Failed to get worktree bootstrap status:', error); + res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' }); + } + }); + + app.delete('/api/git/worktrees', async (req, res) => { + const { removeWorktree } = await getGitLibraries(); + if (typeof removeWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree removal is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; + if (!worktreeDirectory) { + return res.status(400).json({ error: 'worktree directory is required' }); + } + + const result = await removeWorktree(directory, { + directory: worktreeDirectory, + deleteLocalBranch: req.body?.deleteLocalBranch === true, + }); + res.json({ success: Boolean(result) }); + } catch (error) { + console.error('Failed to remove worktree:', error); + res.status(500).json({ error: error.message || 'Failed to remove worktree' }); + } + }); + + app.get('/api/git/worktree-type', async (req, res) => { + const { isLinkedWorktree } = await getGitLibraries(); + try { + const { directory } = req.query; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const linked = await isLinkedWorktree(directory); + res.json({ linked }); + } catch (error) { + console.error('Failed to determine worktree type:', error); + res.status(500).json({ error: error.message || 'Failed to determine worktree type' }); + } + }); + + app.get('/api/git/log', async (req, res) => { + const { getLog } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { maxCount, from, to, file } = req.query; + const log = await getLog(directory, { + maxCount: maxCount ? parseInt(maxCount) : undefined, + from, + to, + file + }); + res.json(log); + } catch (error) { + console.error('Failed to get log:', error); + res.status(500).json({ error: error.message || 'Failed to get commit log' }); + } + }); + + app.get('/api/git/commit-files', async (req, res) => { + const { getCommitFiles } = await getGitLibraries(); + try { + const { directory, hash } = req.query; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + if (!hash) { + return res.status(400).json({ error: 'hash parameter is required' }); + } + + const result = await getCommitFiles(directory, hash); + res.json(result); + } catch (error) { + console.error('Failed to get commit files:', error); + res.status(500).json({ error: error.message || 'Failed to get commit files' }); + } + }); + +} diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index fa3e753d..0496a465 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -820,6 +820,25 @@ const getProjectStoragePath = (projectID) => { return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); }; +const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => { + try { + const { Database } = require('bun:sqlite'); + const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db'); + if (!fs.existsSync(dbPath)) return; + const db = new Database(dbPath); + try { + const row = db.query('SELECT sandboxes FROM project WHERE id = ?').get(projectID); + if (!row) return; + const json = JSON.stringify(sandboxes); + db.query('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID); + } finally { + db.close(); + } + } catch (error) { + console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error)); + } +}; + const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { const storagePath = getProjectStoragePath(projectID); await fsp.mkdir(path.dirname(storagePath), { recursive: true }); @@ -859,6 +878,9 @@ const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { )]; await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); + + // Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK + syncSandboxesToOpenCodeDb(projectID, current.sandboxes); }; const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => { @@ -1187,18 +1209,22 @@ export async function setLocalIdentity(directory, profile) { } } -export async function getStatus(directory) { +export async function getStatus(directory, options = {}) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); + const lightMode = options.mode === 'light'; try { // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); - const [stagedStatsRaw, workingStatsRaw] = await Promise.all([ - git.raw(['diff', '--cached', '--numstat']).catch(() => ''), - git.raw(['diff', '--numstat']).catch(() => ''), - ]); + // Light mode: skip numstat + new-file line counting for faster response + const [stagedStatsRaw, workingStatsRaw] = lightMode + ? ['', ''] + : await Promise.all([ + git.raw(['diff', '--cached', '--numstat']).catch(() => ''), + git.raw(['diff', '--numstat']).catch(() => ''), + ]); const diffStatsMap = new Map(); @@ -1234,7 +1260,7 @@ export async function getStatus(directory) { const diffStats = Object.fromEntries(diffStatsMap.entries()); - const newFileStats = await Promise.all( + const newFileStats = lightMode ? [] : await Promise.all( status.files.map(async (file) => { const working = (file.working_dir || '').trim(); const indexStatus = (file.index || '').trim(); @@ -1333,7 +1359,8 @@ export async function getStatus(directory) { // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind. // We still want to show the number of unpublished commits to the user. - if (!tracking && status.current) { + // Light mode skips this — the basic ahead/behind from git status is sufficient for polling. + if (!lightMode && !tracking && status.current) { const baseRef = await selectBaseRefForUnpublished(); if (baseRef) { const countRaw = await git @@ -1411,7 +1438,7 @@ export async function getStatus(directory) { working_dir: f.working_dir, })), isClean: status.isClean(), - diffStats, + diffStats: lightMode ? undefined : diffStats, mergeInProgress, rebaseInProgress, }; diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index 0248d410..461f52c2 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -8,6 +8,7 @@ ## Entrypoints and structure - `packages/web/server/lib/github/index.js`: public server entrypoint. +- `packages/web/server/lib/github/routes.js`: Express route registration for `/api/github/*` endpoints. - `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config. - `packages/web/server/lib/github/device-flow.js`: OAuth device flow. - `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth. diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 7af68a32..041058e3 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -296,7 +296,20 @@ const parseRepoFromApiUrl = (value) => { } }; +// Track repos where the GitHub Search API returned 403 (token lacks scope for that org) +const _searchApiDisabledRepos = new Map(); +const SEARCH_API_RETRY_MS = 5 * 60 * 1000; // retry after 5 minutes + const searchFallbackPr = async ({ octokit, branch, repoNames }) => { + // Build a repo key to check/store 403 status per-repo + const repoKey = [...repoNames].sort().join(',').toLowerCase(); + + // Skip if this repo set returned 403 recently + const disabledAt = _searchApiDisabledRepos.get(repoKey); + if (disabledAt && Date.now() - disabledAt < SEARCH_API_RETRY_MS) { + return null; + } + const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean)); for (const state of ['open', 'closed']) { @@ -306,8 +319,14 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => { q: `is:pr state:${state} head:${branch}`, per_page: 20, }); + // If we get here, search API works for this repo — clear the disabled flag + _searchApiDisabledRepos.delete(repoKey); } catch (error) { - if (error?.status === 403 || error?.status === 404) { + if (error?.status === 403) { + _searchApiDisabledRepos.set(repoKey, Date.now()); + return null; + } + if (error?.status === 404) { continue; } throw error; diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js new file mode 100644 index 00000000..45579f9c --- /dev/null +++ b/packages/web/server/lib/github/routes.js @@ -0,0 +1,1349 @@ +export function registerGitHubRoutes(app) { + let githubLibraries = null; + const getGitHubLibraries = async () => { + if (!githubLibraries) { + githubLibraries = await import('./index.js'); + } + return githubLibraries; + }; + + const getGitHubUserSummary = async (octokit) => { + const me = await octokit.rest.users.getAuthenticated(); + + let email = typeof me.data.email === 'string' ? me.data.email : null; + if (!email) { + try { + const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 }); + const list = Array.isArray(emails?.data) ? emails.data : []; + const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string'); + const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string'); + email = primaryVerified?.email || anyVerified?.email || null; + } catch { + // ignore (scope might be missing) + } + } + + return { + login: me.data.login, + id: me.data.id, + avatarUrl: me.data.avatar_url, + name: typeof me.data.name === 'string' ? me.data.name : null, + email, + }; + }; + + const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403; + const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404; + + app.get('/api/github/auth/status', async (_req, res) => { + try { + const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const auth = getGitHubAuth(); + const accounts = getGitHubAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts }); + } + + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false, accounts }); + } + + let user = null; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); + } + } + + const fallback = auth.user; + const mergedUser = user || fallback; + + return res.json({ + connected: true, + user: mergedUser, + scope: auth.scope, + accounts, + }); + } catch (error) { + console.error('Failed to get GitHub auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' }); + } + }); + + app.post('/api/github/auth/start', async (_req, res) => { + try { + const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); + const clientId = getGitHubClientId(); + if (!clientId) { + return res.status(400).json({ + error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', + }); + } + + const scope = getGitHubScopes(); + + const payload = await startDeviceFlow({ + clientId, + scope, + }); + + return res.json({ + deviceCode: payload.device_code, + userCode: payload.user_code, + verificationUri: payload.verification_uri, + verificationUriComplete: payload.verification_uri_complete, + expiresIn: payload.expires_in, + interval: payload.interval, + scope, + }); + } catch (error) { + console.error('Failed to start GitHub device flow:', error); + return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' }); + } + }); + + app.post('/api/github/auth/complete', async (req, res) => { + try { + const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const clientId = getGitHubClientId(); + if (!clientId) { + return res.status(400).json({ + error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', + }); + } + + const deviceCode = typeof req.body?.deviceCode === 'string' + ? req.body.deviceCode + : (typeof req.body?.device_code === 'string' ? req.body.device_code : ''); + + if (!deviceCode) { + return res.status(400).json({ error: 'deviceCode is required' }); + } + + const payload = await exchangeDeviceCode({ clientId, deviceCode }); + + if (payload?.error) { + return res.json({ + connected: false, + status: payload.error, + error: payload.error_description || payload.error, + }); + } + + const accessToken = payload?.access_token; + if (!accessToken) { + return res.status(500).json({ error: 'Missing access_token from GitHub' }); + } + + const { Octokit } = await import('@octokit/rest'); + const octokit = new Octokit({ auth: accessToken }); + const user = await getGitHubUserSummary(octokit); + + setGitHubAuth({ + accessToken, + scope: typeof payload.scope === 'string' ? payload.scope : '', + tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer', + user, + }); + + return res.json({ + connected: true, + user, + scope: typeof payload.scope === 'string' ? payload.scope : '', + accounts: getGitHubAuthAccounts(), + }); + } catch (error) { + console.error('Failed to complete GitHub device flow:', error); + return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' }); + } + }); + + app.post('/api/github/auth/activate', async (req, res) => { + try { + const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : ''; + if (!accountId) { + return res.status(400).json({ error: 'accountId is required' }); + } + const activated = activateGitHubAuth(accountId); + if (!activated) { + return res.status(404).json({ error: 'GitHub account not found' }); + } + + const auth = getGitHubAuth(); + const accounts = getGitHubAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts }); + } + + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false, accounts }); + } + + let user = auth.user || null; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); + } + } + + return res.json({ + connected: true, + user, + scope: auth.scope, + accounts, + }); + } catch (error) { + console.error('Failed to activate GitHub account:', error); + return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' }); + } + }); + + app.delete('/api/github/auth', async (_req, res) => { + try { + const { clearGitHubAuth } = await getGitHubLibraries(); + const removed = clearGitHubAuth(); + return res.json({ success: true, removed }); + } catch (error) { + console.error('Failed to disconnect GitHub:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' }); + } + }); + + app.get('/api/github/me', async (_req, res) => { + try { + const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + let user; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.status(401).json({ error: 'GitHub token expired or revoked' }); + } + throw error; + } + return res.json(user); + } catch (error) { + console.error('Failed to fetch GitHub user:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' }); + } + }); + + // ================= GitHub PR APIs ================= + + app.get('/api/github/pr/status', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; + const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; + if (!directory || !branch) { + return res.status(400).json({ error: 'directory and branch are required' }); + } + + const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubPrStatus } = await import('./pr-status.js'); + const resolvedStatus = await resolveGitHubPrStatus({ + octokit, + directory, + branch, + remoteName: remote, + }); + const searchRepo = resolvedStatus.repo; + const first = resolvedStatus.pr; + if (!searchRepo) { + return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null }); + } + if (!first) { + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null }); + } + + // Enrich with mergeability fields + const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number }); + const prData = prFull?.data; + if (!prData) { + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); + } + + // Checks summary: prefer check-runs (Actions), fallback to classic statuses. + let checks = null; + const sha = prData.head?.sha; + if (sha) { + try { + const runs = await octokit.rest.checks.listForRef({ + owner: searchRepo.owner, + repo: searchRepo.repo, + ref: sha, + per_page: 100, + }); + const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; + if (checkRuns.length > 0) { + const counts = { success: 0, failure: 0, pending: 0 }; + for (const run of checkRuns) { + const status = run?.status; + const conclusion = run?.conclusion; + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + continue; + } + if (!conclusion) { + counts.pending += 1; + continue; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + } + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } + } catch { + // ignore and fall back + } + + if (!checks) { + try { + const combined = await octokit.rest.repos.getCombinedStatusForRef({ + owner: searchRepo.owner, + repo: searchRepo.repo, + ref: sha, + }); + const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + if (s.state === 'success') counts.success += 1; + else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; + else if (s.state === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } catch { + checks = null; + } + } + } + + // Permission check (best-effort) + let canMerge = false; + try { + const auth = getGitHubAuth(); + const username = auth?.user?.login; + if (username) { + const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({ + owner: searchRepo.owner, + repo: searchRepo.repo, + username, + }); + const level = perm?.data?.permission; + canMerge = level === 'admin' || level === 'maintain' || level === 'write'; + } + } catch { + canMerge = false; + } + + const isMerged = Boolean(prData.merged || prData.merged_at); + const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + + return res.json({ + connected: true, + repo: searchRepo, + branch, + pr: { + number: prData.number, + title: prData.title, + body: prData.body || '', + url: prData.html_url, + state: mergedState, + draft: Boolean(prData.draft), + base: prData.base?.ref, + head: prData.head?.ref, + headSha: prData.head?.sha, + mergeable: prData.mergeable, + mergeableState: prData.mergeable_state, + }, + checks, + canMerge, + defaultBranch: resolvedStatus.defaultBranch ?? null, + resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null, + }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + if (isGitHubResourceUnavailable(error)) { + return res.json({ + connected: true, + repo: null, + branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '', + pr: null, + checks: null, + canMerge: false, + defaultBranch: null, + resolvedRemoteName: null, + }); + } + console.error('Failed to load GitHub PR status:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' }); + } + }); + + app.post('/api/github/pr/create', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; + const head = typeof req.body?.head === 'string' ? req.body.head.trim() : ''; + const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : ''; + const body = typeof req.body?.body === 'string' ? req.body.body : undefined; + const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined; + // remote = target repo (where PR is created, e.g., 'upstream' for forks) + const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin'; + // headRemote = source repo (where head branch lives, e.g., 'origin' for forks) + const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : ''; + if (!directory || !title || !head || !requestedBase) { + return res.status(400).json({ error: 'directory, title, head, base are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + const normalizeBranchRef = (value, remoteNames = new Set()) => { + if (!value) { + return value; + } + let normalized = value.trim(); + if (normalized.startsWith('refs/heads/')) { + normalized = normalized.substring('refs/heads/'.length); + } + if (normalized.startsWith('heads/')) { + normalized = normalized.substring('heads/'.length); + } + if (normalized.startsWith('remotes/')) { + normalized = normalized.substring('remotes/'.length); + } + + const slashIndex = normalized.indexOf('/'); + if (slashIndex > 0) { + const maybeRemote = normalized.slice(0, slashIndex); + if (remoteNames.has(maybeRemote)) { + const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim(); + if (withoutRemotePrefix) { + normalized = withoutRemotePrefix; + } + } + } + + return normalized; + }; + + // Determine the source remote for the head branch + // Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin + let sourceRemote = headRemote; + const { getStatus, getRemotes } = await import('../git/index.js'); + + // If no explicit headRemote, check the branch's tracking info + if (!sourceRemote) { + const status = await getStatus(directory).catch(() => null); + if (status?.tracking) { + // tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main" + const trackingRemote = status.tracking.split('/')[0]; + if (trackingRemote) { + sourceRemote = trackingRemote; + } + } + } + + // Fallback: if targeting non-origin and no tracking info, try 'origin' + if (!sourceRemote && remote !== 'origin') { + sourceRemote = 'origin'; + } + + const remoteNames = new Set([remote]); + const remotes = await getRemotes(directory).catch(() => []); + for (const item of remotes) { + if (item?.name) { + remoteNames.add(item.name); + } + } + if (sourceRemote) { + remoteNames.add(sourceRemote); + } + + const base = normalizeBranchRef(requestedBase, remoteNames); + if (!base) { + return res.status(400).json({ error: 'Invalid base branch name' }); + } + + // For fork workflows: we need to determine the correct head reference + let headRef = head; + + if (sourceRemote && sourceRemote !== remote) { + // The branch is on a different remote than the target - this is a cross-repo PR + const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote); + if (headRepo) { + // Always use owner:branch format for cross-repo PRs + // GitHub API requires this when head is from a different repo/fork + if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { + headRef = `${headRepo.owner}:${head}`; + } + } + } + + // For cross-repo PRs, verify the branch exists on the head repo first + if (headRef.includes(':')) { + const [headOwner] = headRef.split(':'); + const headRepoName = sourceRemote + ? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo + : repo.repo; + + if (headRepoName) { + try { + await octokit.rest.repos.getBranch({ + owner: headOwner, + repo: headRepoName, + branch: head, + }); + } catch (branchError) { + if (branchError?.status === 404) { + return res.status(400).json({ + error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`, + }); + } + // For other errors, continue - let the PR create attempt handle it + } + } + } + + const created = await octokit.rest.pulls.create({ + owner: repo.owner, + repo: repo.repo, + title, + head: headRef, + base, + ...(typeof body === 'string' ? { body } : {}), + ...(typeof draft === 'boolean' ? { draft } : {}), + }); + + const pr = created?.data; + if (!pr) { + return res.status(500).json({ error: 'Failed to create PR' }); + } + + return res.json({ + number: pr.number, + title: pr.title, + body: pr.body || '', + url: pr.html_url, + state: pr.state === 'closed' ? 'closed' : 'open', + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + }); + } catch (error) { + console.error('Failed to create GitHub PR:', error); + + // Check for head validation error (common with fork PRs) + const errorMessage = error.message || ''; + const isHeadValidationError = + errorMessage.includes('Validation Failed') && + errorMessage.includes('"field":"head"') && + errorMessage.includes('"code":"invalid"'); + + if (isHeadValidationError) { + return res.status(400).json({ + error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.' + }); + } + + return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' }); + } + }); + + app.post('/api/github/pr/update', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; + const body = typeof req.body?.body === 'string' ? req.body.body : undefined; + if (!directory || !number || !title) { + return res.status(400).json({ error: 'directory, number, title are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + let updated; + try { + updated = await octokit.rest.pulls.update({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + title, + ...(typeof body === 'string' ? { body } : {}), + }); + } catch (error) { + if (error?.status === 401) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to edit this PR' }); + } + if (error?.status === 404) { + return res.status(404).json({ error: 'PR not found in this repository' }); + } + if (error?.status === 422) { + const apiMessage = error?.response?.data?.message; + const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0 + ? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code) + : null; + const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload'; + return res.status(422).json({ error: message }); + } + throw error; + } + + const pr = updated?.data; + if (!pr) { + return res.status(500).json({ error: 'Failed to update PR' }); + } + + return res.json({ + number: pr.number, + title: pr.title, + body: pr.body || '', + url: pr.html_url, + state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'), + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + }); + } catch (error) { + console.error('Failed to update GitHub PR:', error); + return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' }); + } + }); + + app.post('/api/github/pr/merge', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + const method = typeof req.body?.method === 'string' ? req.body.method : 'merge'; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + try { + const result = await octokit.rest.pulls.merge({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + merge_method: method, + }); + return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message }); + } catch (error) { + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to merge this PR' }); + } + if (error?.status === 405 || error?.status === 409) { + return res.json({ merged: false, message: error?.message || 'PR not mergeable' }); + } + throw error; + } + } catch (error) { + console.error('Failed to merge GitHub PR:', error); + return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' }); + } + }); + + app.post('/api/github/pr/ready', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); + const nodeId = pr?.data?.node_id; + if (!nodeId) { + return res.status(500).json({ error: 'Failed to resolve PR node id' }); + } + + if (pr?.data?.draft === false) { + return res.json({ ready: true }); + } + + try { + await octokit.graphql( + `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`, + { pullRequestId: nodeId } + ); + } catch (error) { + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to mark PR ready' }); + } + throw error; + } + + return res.json({ ready: true }); + } catch (error) { + console.error('Failed to mark PR ready:', error); + return res.status(500).json({ error: error.message || 'Failed to mark PR ready' }); + } + }); + + // ================= GitHub Issue APIs ================= + + app.get('/api/github/issues/list', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, issues: [] }); + } + + const list = await octokit.rest.issues.listForRepo({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, + }); + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); + const issues = (Array.isArray(list?.data) ? list.data : []) + .filter((item) => !item?.pull_request) + .map((item) => ({ + number: item.number, + title: item.title, + url: item.html_url, + state: item.state === 'closed' ? 'closed' : 'open', + author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null, + labels: Array.isArray(item.labels) + ? item.labels + .map((label) => { + if (typeof label === 'string') return null; + const name = typeof label?.name === 'string' ? label.name : ''; + if (!name) return null; + return { name, color: typeof label?.color === 'string' ? label.color : undefined }; + }) + .filter(Boolean) + : [], + })); + + return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + } catch (error) { + console.error('Failed to list GitHub issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); + } + }); + + app.get('/api/github/issues/get', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, issue: null }); + } + + const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number }); + const issue = result?.data; + if (!issue || issue.pull_request) { + return res.status(400).json({ error: 'Not a GitHub issue' }); + } + + return res.json({ + connected: true, + repo, + issue: { + number: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state === 'closed' ? 'closed' : 'open', + body: issue.body || '', + createdAt: issue.created_at, + updatedAt: issue.updated_at, + author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null, + assignees: Array.isArray(issue.assignees) + ? issue.assignees + .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null)) + .filter(Boolean) + : [], + labels: Array.isArray(issue.labels) + ? issue.labels + .map((label) => { + if (typeof label === 'string') return null; + const name = typeof label?.name === 'string' ? label.name : ''; + if (!name) return null; + return { name, color: typeof label?.color === 'string' ? label.color : undefined }; + }) + .filter(Boolean) + : [], + }, + }); + } catch (error) { + console.error('Failed to fetch GitHub issue:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' }); + } + }); + + app.get('/api/github/issues/comments', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, comments: [] }); + } + + const result = await octokit.rest.issues.listComments({ + owner: repo.owner, + repo: repo.repo, + issue_number: number, + per_page: 100, + }); + const comments = (Array.isArray(result?.data) ? result.data : []) + .map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + return res.json({ connected: true, repo, comments }); + } catch (error) { + console.error('Failed to fetch GitHub issue comments:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' }); + } + }); + + // ================= GitHub Pull Request Context APIs ================= + + app.get('/api/github/pulls/list', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, prs: [] }); + } + + const list = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, + }); + + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); + + const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => { + const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'); + const headRepo = pr.head?.repo + ? { + owner: pr.head.repo.owner?.login, + repo: pr.head.repo.name, + url: pr.head.repo.html_url, + cloneUrl: pr.head.repo.clone_url, + sshUrl: pr.head.repo.ssh_url, + } + : null; + return { + number: pr.number, + title: pr.title, + url: pr.html_url, + state: mergedState, + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null, + headLabel: pr.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url + ? headRepo + : null, + }; + }); + + return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to list GitHub PRs:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); + } + }); + + app.get('/api/github/pulls/context', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; + const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true'; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, pr: null }); + } + + const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); + const prData = prResp?.data; + if (!prData) { + return res.status(404).json({ error: 'PR not found' }); + } + + const headRepo = prData.head?.repo + ? { + owner: prData.head.repo.owner?.login, + repo: prData.head.repo.name, + url: prData.head.repo.html_url, + cloneUrl: prData.head.repo.clone_url, + sshUrl: prData.head.repo.ssh_url, + } + : null; + + const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + const pr = { + number: prData.number, + title: prData.title, + url: prData.html_url, + state: mergedState, + draft: Boolean(prData.draft), + base: prData.base?.ref, + head: prData.head?.ref, + headSha: prData.head?.sha, + mergeable: prData.mergeable, + mergeableState: prData.mergeable_state, + author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null, + headLabel: prData.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null, + body: prData.body || '', + createdAt: prData.created_at, + updatedAt: prData.updated_at, + }; + + const issueCommentsResp = await octokit.rest.issues.listComments({ + owner: repo.owner, + repo: repo.repo, + issue_number: number, + per_page: 100, + }); + const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + path: comment.path, + line: typeof comment.line === 'number' ? comment.line : null, + position: typeof comment.position === 'number' ? comment.position : null, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const filesResp = await octokit.rest.pulls.listFiles({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch, + })); + + // checks summary (same logic as status endpoint) + let checks = null; + let checkRunsOut = undefined; + const sha = prData.head?.sha; + if (sha) { + try { + const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); + const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; + if (checkRuns.length > 0) { + const parsedJobs = new Map(); + const parsedAnnotations = new Map(); + if (includeCheckDetails) { + // Prefetch actions jobs per runId. + const runIds = new Set(); + const jobIds = new Map(); + for (const run of checkRuns) { + const details = typeof run.details_url === 'string' ? run.details_url : ''; + const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + if (match) { + const runId = Number(match[1]); + const jobId = match[2] ? Number(match[2]) : null; + if (Number.isFinite(runId) && runId > 0) { + runIds.add(runId); + if (jobId && Number.isFinite(jobId) && jobId > 0) { + jobIds.set(details, { runId, jobId }); + } else { + jobIds.set(details, { runId, jobId: null }); + } + } + } + } + + for (const runId of runIds) { + try { + const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({ + owner: repo.owner, + repo: repo.repo, + run_id: runId, + per_page: 100, + }); + const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : []; + parsedJobs.set(runId, jobs); + } catch { + parsedJobs.set(runId, []); + } + } + + for (const run of checkRuns) { + const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : ''; + const shouldLoadAnnotations = Boolean( + run?.id + && runConclusion + && !['success', 'neutral', 'skipped'].includes(runConclusion) + ); + if (!shouldLoadAnnotations) { + continue; + } + + const checkRunId = Number(run.id); + if (!Number.isFinite(checkRunId) || checkRunId <= 0) { + continue; + } + + const annotations = []; + for (let page = 1; page <= 3; page += 1) { + try { + const annotationsResp = await octokit.rest.checks.listAnnotations({ + owner: repo.owner, + repo: repo.repo, + check_run_id: checkRunId, + per_page: 50, + page, + }); + const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : []; + annotations.push(...chunk); + if (chunk.length < 50) { + break; + } + } catch { + break; + } + } + + if (annotations.length > 0) { + parsedAnnotations.set(checkRunId, annotations); + } + } + } + + checkRunsOut = checkRuns.map((run) => { + const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined; + let job = undefined; + if (includeCheckDetails && detailsUrl) { + const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + const runId = match ? Number(match[1]) : null; + const jobId = match && match[2] ? Number(match[2]) : null; + if (runId && Number.isFinite(runId)) { + const jobs = parsedJobs.get(runId) || []; + const matched = jobId + ? jobs.find((j) => j.id === jobId) + : null; + const picked = matched || jobs.find((j) => j.name === run.name) || null; + if (picked) { + job = { + runId, + jobId: picked.id, + url: picked.html_url, + name: picked.name, + conclusion: picked.conclusion, + steps: Array.isArray(picked.steps) + ? picked.steps.map((s) => ({ + name: s.name, + status: s.status, + conclusion: s.conclusion, + number: s.number, + startedAt: s.started_at || undefined, + completedAt: s.completed_at || undefined, + })) + : undefined, + }; + } else { + job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl }; + } + } + } + + return { + id: run.id, + name: run.name, + app: run.app + ? { + name: run.app.name || undefined, + slug: run.app.slug || undefined, + } + : undefined, + status: run.status, + conclusion: run.conclusion, + detailsUrl, + output: run.output + ? { + title: run.output.title || undefined, + summary: run.output.summary || undefined, + text: run.output.text || undefined, + } + : undefined, + ...(job ? { job } : {}), + ...(run.id && parsedAnnotations.has(run.id) + ? { + annotations: parsedAnnotations.get(run.id).map((a) => ({ + path: a.path || undefined, + startLine: typeof a.start_line === 'number' ? a.start_line : undefined, + endLine: typeof a.end_line === 'number' ? a.end_line : undefined, + level: a.annotation_level || undefined, + message: a.message || '', + title: a.title || undefined, + rawDetails: a.raw_details || undefined, + })).filter((a) => a.message), + } + : {}), + }; + }); + const counts = { success: 0, failure: 0, pending: 0 }; + for (const run of checkRuns) { + const status = run?.status; + const conclusion = run?.conclusion; + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + continue; + } + if (!conclusion) { + counts.pending += 1; + continue; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + } + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } + } catch { + // ignore and fall back + } + if (!checks) { + try { + const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha }); + const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + if (s.state === 'success') counts.success += 1; + else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; + else if (s.state === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } catch { + checks = null; + } + } + } + + let diff = undefined; + if (includeDiff) { + const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: repo.owner, + repo: repo.repo, + pull_number: number, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined; + } + + return res.json({ + connected: true, + repo, + pr, + issueComments, + reviewComments, + files, + ...(diff ? { diff } : {}), + checks, + ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}), + }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to load GitHub PR context:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' }); + } + }); +} diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 0d0873d7..27ea3a96 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -5,6 +5,11 @@ This module provides notification message preparation utilities for the web serv ## Entrypoints and structure - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints. +- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime. +- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime. +- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout. +- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime. - `packages/web/server/lib/notifications/message.js`: helper implementation module. - `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers. @@ -14,6 +19,68 @@ This module provides notification message preparation utilities for the web serv - `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated. - `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support. +### Route registration API (routes.js) +- `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints: + - `GET /api/push/vapid-public-key` + - `POST /api/push/subscribe` + - `DELETE /api/push/subscribe` + - `POST /api/push/visibility` + - `GET /api/push/visibility` + - `GET /api/session-activity` + - `GET /api/sessions/snapshot` + - `GET /api/sessions/status` + - `GET /api/sessions/:id/status` + - `GET /api/sessions/attention` + - `GET /api/sessions/:id/attention` + - `POST /api/sessions/:id/view` + - `POST /api/sessions/:id/unview` + - `POST /api/sessions/:id/message-sent` + +### Trigger runtime API (runtime.js) +- `createNotificationTriggerRuntime(dependencies)`: creates runtime-owned debounced trigger handling for OpenCode events. +- Returned API: + - `maybeSendPushForTrigger(payload)` +- Owns: + - completion/error/question/permission trigger routing + - session parent cache for subtask suppression + - template resolution and fallback behavior + - native notification fanout and web push payload fanout + +### Push runtime API (push-runtime.js) +- `createPushRuntime(dependencies)`: creates runtime for web push and UI visibility state. +- Returned API: + - `getOrCreateVapidKeys()` + - `ensurePushInitialized()` + - `setPushInitialized(value)` + - `addOrUpdatePushSubscription(uiSessionToken, subscription, userAgent)` + - `removePushSubscription(uiSessionToken, endpoint)` + - `sendPushToAllUiSessions(payload, options?)` + - `updateUiVisibility(token, visible)` + - `isAnyUiVisible()` + - `isUiVisible(token)` + +### Emitter runtime API (emitter-runtime.js) +- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. +- Returned API: + - `writeSseEvent(res, payload)` + - `emitDesktopNotification(payload)` + - `broadcastUiNotification(payload)` + +### Template runtime API (template-runtime.js) +- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template + zen helper runtime. +- Returned API: + - `resolveNotificationTemplate(template, variables)` + - `shouldApplyResolvedTemplateMessage(template, resolved, variables)` + - `fetchFreeZenModels()` + - `resolveZenModel(override)` + - `validateZenModelAtStartup()` + - `summarizeText(text, targetLength, zenModel)` + - `extractLastMessageText(payload, maxLength?)` + - `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)` + - `maybeCacheSessionInfoFromEvent(payload)` + - `buildTemplateVariables(payload, sessionId)` + - `getCachedZenModels()` + ## Constants ### Default values diff --git a/packages/web/server/lib/notifications/emitter-runtime.js b/packages/web/server/lib/notifications/emitter-runtime.js new file mode 100644 index 00000000..2ffc9cfe --- /dev/null +++ b/packages/web/server/lib/notifications/emitter-runtime.js @@ -0,0 +1,65 @@ +export const createNotificationEmitterRuntime = (dependencies) => { + const { + process, + getDesktopNotifyEnabled, + desktopNotifyPrefix, + getUiNotificationClients, + } = dependencies; + + const writeSseEvent = (res, payload) => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + const emitDesktopNotification = (payload) => { + const desktopNotifyEnabled = getDesktopNotifyEnabled(); + if (!desktopNotifyEnabled) { + return; + } + + if (!payload || typeof payload !== 'object') { + return; + } + + try { + // One-line protocol consumed by the Tauri shell. + process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`); + } catch { + // ignore + } + }; + + const broadcastUiNotification = (payload) => { + const desktopNotifyEnabled = getDesktopNotifyEnabled(); + if (!payload || typeof payload !== 'object') { + return; + } + + const clients = getUiNotificationClients(); + if (clients.size === 0) { + return; + } + + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:notification', + properties: { + ...payload, + // Tell the UI whether the sidecar stdout notification channel is active. + // When true, the desktop UI should skip this SSE notification to avoid duplicates. + // When false (e.g. tauri dev), the UI must handle this SSE notification itself. + desktopStdoutActive: desktopNotifyEnabled, + }, + }); + } catch { + // ignore + } + } + }; + + return { + writeSseEvent, + emitDesktopNotification, + broadcastUiNotification, + }; +}; diff --git a/packages/web/server/lib/notifications/index.js b/packages/web/server/lib/notifications/index.js index fb5cecd4..49a8c25d 100644 --- a/packages/web/server/lib/notifications/index.js +++ b/packages/web/server/lib/notifications/index.js @@ -1 +1,4 @@ export { truncateNotificationText, prepareNotificationLastMessage } from './message.js'; +export { createNotificationTriggerRuntime } from './runtime.js'; +export { createPushRuntime } from './push-runtime.js'; +export { createNotificationTemplateRuntime } from './template-runtime.js'; diff --git a/packages/web/server/lib/notifications/push-runtime.js b/packages/web/server/lib/notifications/push-runtime.js new file mode 100644 index 00000000..2cdf1930 --- /dev/null +++ b/packages/web/server/lib/notifications/push-runtime.js @@ -0,0 +1,294 @@ +const PUSH_SUBSCRIPTIONS_VERSION = 1; + +export const createPushRuntime = (deps) => { + const { + fsPromises, + path, + webPush, + PUSH_SUBSCRIPTIONS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + } = deps; + + let persistPushSubscriptionsLock = Promise.resolve(); + let pushInitialized = false; + + const uiVisibilityByToken = new Map(); + let globalVisibilityState = false; + + const readPushSubscriptionsFromDisk = async () => { + try { + const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + + const subscriptionsBySession = + parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object' + ? parsed.subscriptionsBySession + : {}; + + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + console.warn('Failed to read push subscriptions file:', error); + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + }; + + const writePushSubscriptionsToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); + }; + + const persistPushSubscriptionUpdate = async (mutate) => { + persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => { + await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); + const current = await readPushSubscriptionsFromDisk(); + const next = mutate({ + version: PUSH_SUBSCRIPTIONS_VERSION, + subscriptionsBySession: current.subscriptionsBySession || {}, + }); + await writePushSubscriptionsToDisk(next); + return next; + }); + + return persistPushSubscriptionsLock; + }; + + const getOrCreateVapidKeys = async () => { + const settings = await readSettingsFromDiskMigrated(); + const existing = settings?.vapidKeys; + if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') { + return { publicKey: existing.publicKey, privateKey: existing.privateKey }; + } + + const generated = webPush.generateVAPIDKeys(); + const next = { + ...settings, + vapidKeys: { + publicKey: generated.publicKey, + privateKey: generated.privateKey, + }, + }; + + await writeSettingsToDisk(next); + return { publicKey: generated.publicKey, privateKey: generated.privateKey }; + }; + + const normalizePushSubscriptions = (record) => { + if (!Array.isArray(record)) return []; + return record + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const endpoint = entry.endpoint; + const p256dh = entry.p256dh; + const auth = entry.auth; + if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') { + return null; + } + return { + endpoint, + p256dh, + auth, + createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + }; + }) + .filter(Boolean); + }; + + const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { + if (!uiSessionToken) { + return; + } + + await ensurePushInitialized(); + + const now = Date.now(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; + + const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); + + filtered.unshift({ + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + createdAt: now, + lastSeenAt: now, + userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + }); + + subsBySession[uiSessionToken] = filtered.slice(0, 10); + + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const removePushSubscription = async (uiSessionToken, endpoint) => { + if (!uiSessionToken || !endpoint) return; + + await ensurePushInitialized(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; + const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); + if (filtered.length === 0) { + delete subsBySession[uiSessionToken]; + } else { + subsBySession[uiSessionToken] = filtered; + } + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const removePushSubscriptionFromAllSessions = async (endpoint) => { + if (!endpoint) return; + + await ensurePushInitialized(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + for (const [token, entries] of Object.entries(subsBySession)) { + if (!Array.isArray(entries)) continue; + const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); + if (filtered.length === 0) { + delete subsBySession[token]; + } else { + subsBySession[token] = filtered; + } + } + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const sendPushToSubscription = async (sub, payload) => { + await ensurePushInitialized(); + const body = JSON.stringify(payload); + + const pushSubscription = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }; + + try { + await webPush.sendNotification(pushSubscription, body); + } catch (error) { + const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; + if (statusCode === 410 || statusCode === 404) { + await removePushSubscriptionFromAllSessions(sub.endpoint); + return; + } + console.warn('[Push] Failed to send notification:', error); + } + }; + + const sendPushToAllUiSessions = async (payload, options = {}) => { + const requireNoSse = options.requireNoSse === true; + const store = await readPushSubscriptionsFromDisk(); + const sessions = store.subscriptionsBySession || {}; + const subscriptionsByEndpoint = new Map(); + + for (const record of Object.values(sessions)) { + const subscriptions = normalizePushSubscriptions(record); + if (subscriptions.length === 0) continue; + + for (const sub of subscriptions) { + if (!subscriptionsByEndpoint.has(sub.endpoint)) { + subscriptionsByEndpoint.set(sub.endpoint, sub); + } + } + } + + await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => { + if (requireNoSse && isAnyUiVisible()) { + return; + } + await sendPushToSubscription(sub, payload); + })); + }; + + const updateUiVisibility = (token, visible) => { + if (!token) return; + const now = Date.now(); + const nextVisible = Boolean(visible); + uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); + globalVisibilityState = nextVisible; + }; + + const isAnyUiVisible = () => globalVisibilityState === true; + + const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; + + const resolveVapidSubject = async () => { + const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; + if (typeof configured === 'string' && configured.trim().length > 0) { + return configured.trim(); + } + + const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN; + if (typeof originEnv === 'string' && originEnv.trim().length > 0) { + const trimmed = originEnv.trim(); + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const stored = settings?.publicOrigin; + if (typeof stored === 'string' && stored.trim().length > 0) { + const trimmed = stored.trim(); + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; + } + } catch { + } + + return 'mailto:openchamber@localhost'; + }; + + const ensurePushInitialized = async () => { + if (pushInitialized) return; + const keys = await getOrCreateVapidKeys(); + const subject = await resolveVapidSubject(); + + if (subject === 'mailto:openchamber@localhost') { + console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.'); + } + + webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey); + pushInitialized = true; + }; + + const setPushInitialized = (value) => { + pushInitialized = value === true; + }; + + return { + getOrCreateVapidKeys, + addOrUpdatePushSubscription, + removePushSubscription, + sendPushToAllUiSessions, + updateUiVisibility, + isAnyUiVisible, + isUiVisible, + ensurePushInitialized, + setPushInitialized, + }; +}; diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js new file mode 100644 index 00000000..7be9883c --- /dev/null +++ b/packages/web/server/lib/notifications/routes.js @@ -0,0 +1,247 @@ +const parsePushSubscribeBody = (body) => { + if (!body || typeof body !== 'object') return null; + const endpoint = body.endpoint; + const keys = body.keys; + const p256dh = keys?.p256dh; + const auth = keys?.auth; + + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; + if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null; + if (typeof auth !== 'string' || auth.trim().length === 0) return null; + + return { + endpoint: endpoint.trim(), + keys: { p256dh: p256dh.trim(), auth: auth.trim() }, + }; +}; + +const parsePushUnsubscribeBody = (body) => { + if (!body || typeof body !== 'object') return null; + const endpoint = body.endpoint; + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; + return { endpoint: endpoint.trim() }; +}; + +export const registerNotificationRoutes = (app, dependencies) => { + const { + uiAuthController, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + getSessionActivitySnapshot, + getSessionStateSnapshot, + getSessionAttentionSnapshot, + getSessionState, + getSessionAttentionState, + markSessionViewed, + markSessionUnviewed, + markUserMessageSent, + setPushInitialized, + } = dependencies; + + app.get('/api/push/vapid-public-key', async (_req, res) => { + try { + await ensurePushInitialized(); + const keys = await getOrCreateVapidKeys(); + res.json({ publicKey: keys.publicKey }); + } catch (error) { + console.warn('[Push] Failed to load VAPID key:', error); + res.status(500).json({ error: 'Failed to load push key' }); + } + }); + + app.post('/api/push/subscribe', async (req, res) => { + await ensurePushInitialized(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const parsed = parsePushSubscribeBody(req.body); + if (!parsed) { + return res.status(400).json({ error: 'Invalid body' }); + } + + const { endpoint, keys } = parsed; + + const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : ''; + if (origin.startsWith('http://') || origin.startsWith('https://')) { + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) { + await writeSettingsToDisk({ + ...settings, + publicOrigin: origin, + }); + setPushInitialized(false); + } + } catch { + } + } + + await addOrUpdatePushSubscription( + uiToken, + { + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + }, + req.headers['user-agent'] + ); + + return res.json({ ok: true }); + }); + + app.delete('/api/push/subscribe', async (req, res) => { + await ensurePushInitialized(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const parsed = parsePushUnsubscribeBody(req.body); + if (!parsed) { + return res.status(400).json({ error: 'Invalid body' }); + } + + await removePushSubscription(uiToken, parsed.endpoint); + return res.json({ ok: true }); + }); + + app.post('/api/push/visibility', async (req, res) => { + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; + updateUiVisibility(uiToken, visible === true); + return res.json({ ok: true }); + }); + + app.get('/api/push/visibility', (req, res) => { + const uiToken = getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + return res.json({ + ok: true, + visible: isUiVisible(uiToken), + }); + }); + + app.get('/api/session-activity', (_req, res) => { + res.json(getSessionActivitySnapshot()); + }); + + app.get('/api/sessions/snapshot', (_req, res) => { + res.json({ + statusSessions: getSessionStateSnapshot(), + attentionSessions: getSessionAttentionSnapshot(), + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/status', (_req, res) => { + const snapshot = getSessionStateSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/:id/status', (req, res) => { + const sessionId = req.params.id; + const state = getSessionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no state available', + sessionId, + }); + } + + return res.json({ + sessionId, + ...state, + }); + }); + + app.get('/api/sessions/attention', (_req, res) => { + const snapshot = getSessionAttentionSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/:id/attention', (req, res) => { + const sessionId = req.params.id; + const state = getSessionAttentionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no attention state available', + sessionId, + }); + } + + return res.json({ + sessionId, + ...state, + }); + }); + + app.post('/api/sessions/:id/view', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionViewed(sessionId, clientId); + + return res.json({ + success: true, + sessionId, + viewed: true, + }); + }); + + app.post('/api/sessions/:id/unview', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionUnviewed(sessionId, clientId); + + return res.json({ + success: true, + sessionId, + viewed: false, + }); + }); + + app.post('/api/sessions/:id/message-sent', (req, res) => { + const sessionId = req.params.id; + + markUserMessageSent(sessionId); + + return res.json({ + success: true, + sessionId, + messageSent: true, + }); + }); +}; diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js new file mode 100644 index 00000000..8bd276d6 --- /dev/null +++ b/packages/web/server/lib/notifications/runtime.js @@ -0,0 +1,471 @@ +export const createNotificationTriggerRuntime = (deps) => { + const { + readSettingsFromDisk, + prepareNotificationLastMessage, + summarizeText, + resolveZenModel, + buildTemplateVariables, + extractLastMessageText, + fetchLastAssistantMessageText, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + emitDesktopNotification, + broadcastUiNotification, + sendPushToAllUiSessions, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + } = deps; + + const PUSH_READY_COOLDOWN_MS = 5000; + const PUSH_QUESTION_DEBOUNCE_MS = 500; + const PUSH_PERMISSION_DEBOUNCE_MS = 500; + const pushQuestionDebounceTimers = new Map(); + const pushPermissionDebounceTimers = new Map(); + const notifiedPermissionRequests = new Set(); + const lastReadyNotificationAt = new Map(); + + const sessionParentIdCache = new Map(); + const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; + + const buildSessionDeepLinkUrl = (sessionId) => { + if (!sessionId || typeof sessionId !== 'string') { + return '/'; + } + return `/?session=${encodeURIComponent(sessionId)}`; + }; + + const getCachedSessionParentId = (sessionId) => { + const entry = sessionParentIdCache.get(sessionId); + if (!entry) return undefined; + if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) { + sessionParentIdCache.delete(sessionId); + return undefined; + } + return entry.parentID; + }; + + const setCachedSessionParentId = (sessionId, parentID) => { + sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() }); + }; + + const fetchSessionParentId = async (sessionId) => { + if (!sessionId) return undefined; + + const cached = getCachedSessionParentId(sessionId); + if (cached !== undefined) return cached; + + try { + const response = await fetch(buildOpenCodeUrl('/session', ''), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + return undefined; + } + const data = await response.json().catch(() => null); + if (!Array.isArray(data)) { + return undefined; + } + + const match = data.find((session) => session && typeof session === 'object' && session.id === sessionId); + const parentID = match?.parentID ? match.parentID : null; + setCachedSessionParentId(sessionId, parentID); + return parentID; + } catch { + return undefined; + } + }; + + const extractSessionIdFromPayload = (payload) => { + if (!payload || typeof payload !== 'object') return null; + const props = payload.properties; + const info = props?.info; + const sessionId = + info?.sessionID ?? + info?.sessionId ?? + props?.sessionID ?? + props?.sessionId ?? + props?.session ?? + null; + return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null; + }; + + const formatMode = (raw) => { + const value = typeof raw === 'string' ? raw.trim() : ''; + const normalized = value.length > 0 ? value : 'agent'; + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(' '); + }; + + const formatModelId = (raw) => { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) { + return 'Assistant'; + } + + const tokens = value.split(/[-_]+/).filter(Boolean); + const result = []; + for (let i = 0; i < tokens.length; i += 1) { + const current = tokens[i]; + const next = tokens[i + 1]; + if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) { + result.push(`${current}.${next}`); + i += 1; + continue; + } + result.push(current); + } + + return result + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + }; + + const maybeSendPushForTrigger = async (payload) => { + if (!payload || typeof payload !== 'object') { + return; + } + + const sessionId = extractSessionIdFromPayload(payload); + + if (payload.type === 'message.updated') { + const info = payload.properties?.info; + if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { + const settings = await readSettingsFromDisk(); + + if (settings.notifyOnSubtasks === false) { + const sessionInfo = payload.properties?.session; + const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID; + const parentID = parentIDFromPayload + ? parentIDFromPayload + : await fetchSessionParentId(sessionId); + + if (parentID) { + return; + } + } + + if (settings.notifyOnCompletion === false) { + return; + } + + const now = Date.now(); + const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0; + if (now - lastAt < PUSH_READY_COOLDOWN_MS) { + return; + } + lastReadyNotificationAt.set(sessionId, now); + + let title = `${formatMode(info?.mode)} agent is ready`; + let body = `${formatModelId(info?.modelID)} completed the task`; + + try { + const templates = settings.notificationTemplates || {}; + const isSubtask = await fetchSessionParentId(sessionId); + const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false + ? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }) + : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); + + const variables = await buildTemplateVariables(payload, sessionId); + + const messageId = info?.id; + let lastMessage = extractLastMessageText(payload); + if (!lastMessage) { + lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); + } + + const notifZenModel = await resolveZenModel(settings?.zenModel); + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: (text, len) => summarizeText(text, len, notifZenModel), + }); + + const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + const notificationPayload = { + title, + body, + tag: `ready-${sessionId}`, + kind: 'ready', + sessionId, + requireHidden: settings.notificationMode !== 'always', + }; + emitDesktopNotification(notificationPayload); + broadcastUiNotification(notificationPayload); + } + + await sendPushToAllUiSessions( + { + title, + body, + tag: `ready-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'ready', + }, + }, + { requireNoSse: true }, + ); + } + + if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) { + const settings = await readSettingsFromDisk(); + if (settings.notifyOnError === false) return; + + let title = 'Tool error'; + let body = 'An error occurred'; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + const errorMessageId = info?.id; + let lastMessage = extractLastMessageText(payload); + if (!lastMessage) { + lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); + } + + const errZenModel = await resolveZenModel(settings?.zenModel); + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: (text, len) => summarizeText(text, len, errZenModel), + }); + + const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; + const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + const notificationPayload = { + title, + body, + tag: `error-${sessionId}`, + kind: 'error', + sessionId, + requireHidden: settings.notificationMode !== 'always', + }; + emitDesktopNotification(notificationPayload); + broadcastUiNotification(notificationPayload); + } + + await sendPushToAllUiSessions( + { + title, + body, + tag: `error-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'error', + }, + }, + { requireNoSse: true }, + ); + } + + return; + } + + if (payload.type === 'question.asked' && sessionId) { + const existingTimer = pushQuestionDebounceTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(async () => { + pushQuestionDebounceTimers.delete(sessionId); + + const settings = await readSettingsFromDisk(); + if (settings.notifyOnQuestion === false) { + return; + } + + const firstQuestion = payload.properties?.questions?.[0]; + const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; + const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; + + let title = /plan\s*mode/i.test(header) + ? 'Switch to plan mode' + : /build\s*agent/i.test(header) + ? 'Switch to build mode' + : header || 'Input needed'; + let body = questionText || 'Agent is waiting for your response'; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + variables.last_message = questionText || header || ''; + + const templates = settings.notificationTemplates || {}; + const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' }; + + const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + emitDesktopNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + } + + void sendPushToAllUiSessions( + { + title, + body, + tag: `question-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'question', + }, + }, + { requireNoSse: true }, + ); + }, PUSH_QUESTION_DEBOUNCE_MS); + + pushQuestionDebounceTimers.set(sessionId, timer); + return; + } + + if (payload.type === 'permission.replied' && sessionId) { + const requestId = payload.properties?.requestID; + const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; + const pendingNotification = pushPermissionDebounceTimers.get(sessionId); + if (requestKey && pendingNotification?.requestKey === requestKey) { + clearTimeout(pendingNotification.timer); + pushPermissionDebounceTimers.delete(sessionId); + } + return; + } + + if (payload.type === 'permission.asked' && sessionId) { + const requestId = payload.properties?.id; + const permission = payload.properties?.permission; + const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; + if (requestKey && notifiedPermissionRequests.has(requestKey)) { + return; + } + + const existingTimer = pushPermissionDebounceTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer.timer); + } + + const timer = setTimeout(async () => { + pushPermissionDebounceTimers.delete(sessionId); + + const settings = await readSettingsFromDisk(); + + if (settings.notifyOnQuestion === false) { + return; + } + + const sessionTitle = payload.properties?.sessionTitle; + const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : ''; + const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0 + ? sessionTitle.trim() + : permissionText || 'Agent is waiting for your approval'; + + let title = 'Permission required'; + let body = fallbackMessage; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + variables.last_message = fallbackMessage; + + const templates = settings.notificationTemplates || {}; + const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' }; + + const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + emitDesktopNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + } + + if (requestKey) { + notifiedPermissionRequests.add(requestKey); + } + + void sendPushToAllUiSessions( + { + title, + body, + tag: `permission-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'permission', + }, + }, + { requireNoSse: true }, + ); + }, PUSH_PERMISSION_DEBOUNCE_MS); + + pushPermissionDebounceTimers.set(sessionId, { timer, requestKey }); + } + }; + + return { + maybeSendPushForTrigger, + }; +}; diff --git a/packages/web/server/lib/notifications/template-runtime.js b/packages/web/server/lib/notifications/template-runtime.js new file mode 100644 index 00000000..f0641174 --- /dev/null +++ b/packages/web/server/lib/notifications/template-runtime.js @@ -0,0 +1,436 @@ +export const createNotificationTemplateRuntime = (deps) => { + const { + readSettingsFromDisk, + persistSettings, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + resolveGitBinaryForSpawn, + } = deps; + + const NOTIFICATION_BODY_MAX_CHARS = 1000; + const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; + const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; + const SESSION_INFO_CACHE_TTL_MS = 60 * 1000; + + let validatedZenFallback = null; + let cachedZenModels = null; + let cachedZenModelsTimestamp = 0; + + const sessionTitleCache = new Map(); + const sessionInfoCache = new Map(); + + const createTimeoutSignal = (timeoutMs) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timer), + }; + }; + + const formatProjectLabel = (label) => { + if (!label || typeof label !== 'string') return ''; + return label + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); + }; + + const resolveNotificationTemplate = (template, variables) => { + if (!template || typeof template !== 'string') return ''; + return template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = variables[key]; + if (value === undefined || value === null) return ''; + return String(value); + }); + }; + + const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { + if (!resolved) { + return false; + } + + if (typeof template !== 'string') { + return true; + } + + if (template.includes('{last_message}')) { + return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0; + } + + return true; + }; + + const fetchFreeZenModels = async () => { + const now = Date.now(); + if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { + return cachedZenModels.models; + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; + try { + const response = await fetch('https://opencode.ai/zen/v1/models', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`zen/v1/models responded with status ${response.status}`); + } + const data = await response.json(); + const allModels = Array.isArray(data?.data) ? data.data : []; + const freeModels = allModels + .filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free')) + .map((model) => ({ id: model.id, owned_by: model.owned_by })); + + cachedZenModels = { models: freeModels }; + cachedZenModelsTimestamp = Date.now(); + return freeModels; + } finally { + if (timeout) clearTimeout(timeout); + } + }; + + const resolveZenModel = async (override) => { + if (typeof override === 'string' && override.trim().length > 0) { + return override.trim(); + } + try { + const settings = await readSettingsFromDisk(); + if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { + return settings.zenModel.trim(); + } + } catch { + } + return validatedZenFallback || ZEN_DEFAULT_MODEL; + }; + + const validateZenModelAtStartup = async () => { + try { + const freeModels = await fetchFreeZenModels(); + const freeModelIds = freeModels.map((model) => model.id); + + if (freeModelIds.length > 0) { + validatedZenFallback = freeModelIds[0]; + + const settings = await readSettingsFromDisk(); + const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; + + if (!storedModel || !freeModelIds.includes(storedModel)) { + const fallback = freeModelIds[0]; + console.log( + storedModel + ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` + : `[zen] No model configured, setting default to "${fallback}"` + ); + await persistSettings({ zenModel: fallback }); + } else { + console.log(`[zen] Stored model "${storedModel}" verified as available`); + } + } else { + console.warn('[zen] No free models returned from API, skipping validation'); + } + } catch (error) { + console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); + } + }; + + const summarizeText = async (text, targetLength, zenModel) => { + if (!text || typeof text !== 'string' || text.trim().length === 0) return text; + + try { + const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`; + + const completionTimeout = createTimeoutSignal(15000); + let response; + try { + response = await fetch('https://opencode.ai/zen/v1/responses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: zenModel || ZEN_DEFAULT_MODEL, + input: [{ role: 'user', content: prompt }], + max_output_tokens: 1000, + stream: false, + reasoning: { effort: 'low' }, + }), + signal: completionTimeout.signal, + }); + } finally { + completionTimeout.cleanup(); + } + + if (!response.ok) return text; + + const data = await response.json(); + const summary = data?.output?.find((item) => item?.type === 'message') + ?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); + + return summary || text; + } catch { + return text; + } + }; + + const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + if (!Array.isArray(parts) || parts.length === 0) return ''; + + const textParts = parts + .filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string')) + .map((part) => part.text || part.content || '') + .filter(Boolean); + + let text = textParts.length > 0 ? textParts.join('\n').trim() : ''; + + if (maxLength > 0 && text.length > maxLength) { + text = text.slice(0, maxLength); + } + + return text; + }; + + const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + const info = payload?.properties?.info; + if (!info) return ''; + + const parts = info.parts || payload?.properties?.parts; + const text = extractTextFromParts(parts, maxLength); + if (text) return text; + + const content = info.content; + if (Array.isArray(content)) { + const textContent = content + .filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string')) + .map((entry) => entry.text || '') + .filter(Boolean); + if (textContent.length > 0) { + let result = textContent.join('\n').trim(); + if (maxLength > 0 && result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; + } + } + + return ''; + }; + + const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + if (!sessionId) return ''; + + try { + const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); + const response = await fetch(`${url}?limit=5`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(3000), + }); + + if (!response.ok) return ''; + + const messages = await response.json().catch(() => null); + if (!Array.isArray(messages)) return ''; + + let target = null; + if (messageId) { + target = messages.find((message) => message?.info?.id === messageId && message?.info?.role === 'assistant'); + } + if (!target) { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.info?.role === 'assistant' && message?.info?.finish === 'stop') { + target = message; + break; + } + } + } + + if (!target || !Array.isArray(target.parts)) return ''; + + return extractTextFromParts(target.parts, maxLength); + } catch { + return ''; + } + }; + + const cacheSessionTitle = (sessionId, title) => { + if (typeof sessionId === 'string' && sessionId.length > 0 && typeof title === 'string' && title.length > 0) { + sessionTitleCache.set(sessionId, title); + } + }; + + const getCachedSessionTitle = (sessionId) => { + return sessionTitleCache.get(sessionId) ?? null; + }; + + const maybeCacheSessionInfoFromEvent = (payload) => { + if (!payload || typeof payload !== 'object') return; + const type = payload.type; + if (type !== 'session.updated' && type !== 'session.created') return; + const info = payload.properties?.info; + if (!info || typeof info !== 'object') return; + cacheSessionTitle(info.id, info.title); + }; + + const fetchSessionInfo = async (sessionId) => { + if (!sessionId) return null; + + const cached = sessionInfoCache.get(sessionId); + if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) { + return cached.data; + } + + try { + const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`); + return null; + } + const data = await response.json().catch(() => null); + if (data && typeof data === 'object') { + sessionInfoCache.set(sessionId, { data, at: Date.now() }); + return data; + } + return null; + } catch (error) { + console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, error?.message || error); + return null; + } + }; + + const buildTemplateVariables = async (payload, sessionId) => { + const info = payload?.properties?.info || {}; + + let sessionTitle = payload?.properties?.sessionTitle || payload?.properties?.session?.title || (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || ''; + + if (!sessionTitle && sessionId) { + const cached = getCachedSessionTitle(sessionId); + if (cached) { + sessionTitle = cached; + } + } + + let sessionInfo = null; + if (!sessionTitle && sessionId) { + sessionInfo = await fetchSessionInfo(sessionId); + if (sessionInfo && typeof sessionInfo.title === 'string') { + sessionTitle = sessionInfo.title; + cacheSessionTitle(sessionId, sessionTitle); + } + } + + const agentName = (() => { + const mode = typeof info.agent === 'string' && info.agent.trim().length > 0 + ? info.agent.trim() + : (typeof info.mode === 'string' ? info.mode.trim() : ''); + if (!mode) return 'Agent'; + return mode.split(/[-_\s]+/).filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' '); + })(); + + const modelName = (() => { + const raw = typeof info.modelID === 'string' ? info.modelID.trim() + : (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : ''); + if (!raw) return 'Assistant'; + return raw.split(/[-_]+/).filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); + })(); + + let projectName = ''; + let branch = ''; + let worktreeDir = ''; + + const infoPath = info.path; + if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) { + worktreeDir = infoPath.root; + } else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) { + worktreeDir = infoPath.cwd; + } + + try { + const settings = await readSettingsFromDisk(); + const projects = Array.isArray(settings.projects) ? settings.projects : []; + + if (worktreeDir) { + const normalizedDir = worktreeDir.replace(/\/+$/, ''); + const matchedProject = projects.find((project) => { + if (!project || typeof project.path !== 'string') return false; + return project.path.replace(/\/+$/, '') === normalizedDir; + }); + if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) { + projectName = matchedProject.label.trim(); + } else { + projectName = normalizedDir.split('/').filter(Boolean).pop() || ''; + } + } else { + const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const activeProject = activeId ? projects.find((project) => project && project.id === activeId) : projects[0]; + if (activeProject) { + projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0 + ? activeProject.label.trim() + : typeof activeProject.path === 'string' + ? activeProject.path.split('/').pop() || '' + : ''; + worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : ''; + } + } + } catch { + if (worktreeDir && !projectName) { + projectName = worktreeDir.split('/').filter(Boolean).pop() || ''; + } + } + + if (worktreeDir) { + try { + const { simpleGit } = await import('simple-git'); + const git = simpleGit({ + baseDir: worktreeDir, + spawnOptions: { windowsHide: true }, + binary: resolveGitBinaryForSpawn(), + }); + branch = await Promise.race([ + git.revparse(['--abbrev-ref', 'HEAD']), + new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)), + ]).catch(() => ''); + } catch { + } + } + + return { + project_name: formatProjectLabel(projectName), + worktree: worktreeDir, + branch: typeof branch === 'string' ? branch.trim() : '', + session_name: sessionTitle, + agent_name: agentName, + model_name: modelName, + last_message: '', + session_id: sessionId || '', + }; + }; + + const getCachedZenModels = () => cachedZenModels; + + return { + createTimeoutSignal, + formatProjectLabel, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + fetchFreeZenModels, + resolveZenModel, + validateZenModelAtStartup, + summarizeText, + extractTextFromParts, + extractLastMessageText, + fetchLastAssistantMessageText, + maybeCacheSessionInfoFromEvent, + buildTemplateVariables, + getCachedZenModels, + }; +}; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 843d6f93..85e87ff5 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -6,6 +6,39 @@ This module provides OpenCode server integration utilities for the web server ru ## Entrypoints and structure - `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder). - `packages/web/server/lib/opencode/auth.js`: provider authentication file operations. +- `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime. +- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments. +- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap. +- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration. +- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). +- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime. +- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname). +- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers. +- `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring. +- `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime. +- `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime. +- `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration and reload semantics. +- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments. +- `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration. +- `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/terminal/process/server teardown. +- `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime. +- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring. +- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration. +- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics. +- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup. +- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow. +- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers. +- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration. +- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching. +- `packages/web/server/lib/opencode/project-icon-routes.js`: project icon upload/read/discovery route registration and icon storage orchestration. +- `packages/web/server/lib/opencode/skill-routes.js`: route registration for skill config CRUD, supporting files, and skills catalog scan/install flows. +- `packages/web/server/lib/opencode/settings-runtime.js`: Settings persistence runtime (disk IO, migrations, normalization, project validation, and persisted update serialization). +- `packages/web/server/lib/opencode/settings-helpers.js`: Settings payload sanitization/format helpers runtime for response shaping and persisted merge prep. +- `packages/web/server/lib/opencode/settings-normalization-runtime.js`: path/settings/tunnel normalization and sanitization helpers runtime used by settings/routes/config wiring. +- `packages/web/server/lib/opencode/theme-runtime.js`: custom theme JSON validation and theme directory loading runtime for settings utility routes. +- `packages/web/server/lib/opencode/proxy.js`: OpenCode API/SSE forwarding and readiness-gate route registration. +- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events. +- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout. - `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers. - `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting. @@ -43,6 +76,269 @@ This module provides OpenCode server integration utilities for the web server ru - `ensureSessionToken(req, res)`: Returns or creates session token. - `dispose()`: Cleans up timers and state. +## Public exports (routes.js) +- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime: + - `GET /api/config/settings` + - `PUT /api/config/settings` + - `GET /api/config/opencode-resolution` + - `POST /api/opencode/directory` + - `GET /api/provider/:providerId/source` + - `DELETE /api/provider/:providerId/auth` +- Owns lazy auth library loading for provider auth checks/removal. +- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only. + +## Public exports (session-runtime.js) +- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status. +- Returned API: + - `processOpenCodeSsePayload(payload)` + - `getSessionActivitySnapshot()` + - `getSessionStateSnapshot()` + - `getSessionAttentionSnapshot()` + - `getSessionState(sessionId)` + - `getSessionAttentionState(sessionId)` + - `markSessionViewed(sessionId, clientId)` + - `markSessionUnviewed(sessionId, clientId)` + - `markUserMessageSent(sessionId)` + - `resetAllSessionActivityToIdle()` + - `dispose()` + +## Public exports (lifecycle.js) +- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. +- Returned API: + - `startOpenCode()` + - `restartOpenCode()` + - `waitForOpenCodeReady(timeoutMs?, intervalMs?)` + - `waitForAgentPresence(agentName, timeoutMs?, intervalMs?)` + - `refreshOpenCodeAfterConfigChange(reason, options?)` + - `bootstrapOpenCodeAtStartup()` + - `startHealthMonitoring(healthCheckIntervalMs)` + - `killProcessOnPort(port)` + +## Public exports (env-runtime.js) +- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state. +- Returned API: + - `applyLoginShellEnvSnapshot()` + - `getLoginShellEnvSnapshot()` + - `ensureOpencodeCliEnv()` + - `applyOpencodeBinaryFromSettings()` + - `resolveOpencodeCliPath()` + - `resolveGitBinaryForSpawn()` + - `resolveWslExecutablePath()` + - `buildWslExecArgs(execArgs, distroOverride?)` + - `opencodeShimInterpreter(opencodePath)` + - `isExecutable(filePath)` + - `searchPathFor(binaryName)` + - `clearResolvedOpenCodeBinary()` + +## Public exports (env-config.js) +- `resolveOpenCodeEnvConfig(options?)`: resolves and validates OpenCode host/port/hostname environment configuration. +- Returned object fields: + - `configuredOpenCodePort` + - `configuredOpenCodeHost` + - `effectivePort` + - `configuredOpenCodeHostname` + +## Public exports (hmr-state-runtime.js) +- `createHmrStateRuntime(dependencies)`: creates runtime for HMR state container initialization and runtime<->HMR state synchronization. +- Returned API: + - `getOrCreateHmrState()` + - `ensureUserProvidedOpenCodePassword(hmrState)` + - `getUserProvidedOpenCodePassword(hmrState)` + - `resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword })` + - `syncStateFromRuntime(hmrState, runtime)` + - `restoreRuntimeFromState({ hmrState, userProvidedOpenCodePassword })` + +## Public exports (bootstrap-runtime.js) +- `createBootstrapRuntime(dependencies)`: creates runtime for base app route bootstrap and UI auth controller initialization. +- Returned API: + - `setupBaseRoutes(app, options)` + +## Public exports (network-runtime.js) +- `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns. +- Returned API: + - `waitForReady(url, timeoutMs?)` + - `normalizeApiPrefix(prefix)` + - `setDetectedOpenCodeApiPrefix()` + - `buildOpenCodeUrl(path, prefixOverride?)` + - `ensureOpenCodeApiPrefix()` + - `scheduleOpenCodeApiDetection()` + +## Public exports (settings-runtime.js) +- `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns. +- Returned API: + - `readSettingsFromDisk()` + - `readSettingsFromDiskMigrated()` + - `writeSettingsToDisk(settings)` + - `persistSettings(changes)` + +## Public exports (settings-helpers.js) +- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping. +- Returned API: + - `normalizePwaAppName(value, fallback?)` + - `sanitizeSettingsUpdate(payload)` + - `mergePersistedSettings(current, changes)` + - `formatSettingsResponse(settings)` + +## Public exports (settings-normalization-runtime.js) +- `createSettingsNormalizationRuntime(dependencies)`: creates normalization/sanitization runtime for shared settings and tunnel helper logic. +- Returned API: + - `normalizeDirectoryPath(value)` + - `normalizePathForPersistence(value)` + - `normalizeSettingsPaths(input)` + - `normalizeTunnelBootstrapTtlMs(value)` + - `normalizeTunnelSessionTtlMs(value)` + - `normalizeManagedRemoteTunnelHostname(value)` + - `normalizeManagedRemoteTunnelPresets(value)` + - `normalizeManagedRemoteTunnelPresetTokens(value)` + - `isUnsafeSkillRelativePath(value)` + - `sanitizeTypographySizesPartial(input)` + - `normalizeStringArray(input)` + - `sanitizeModelRefs(input, limit)` + - `sanitizeSkillCatalogs(input)` + - `sanitizeProjects(input)` + +## Public exports (theme-runtime.js) +- `createThemeRuntime(dependencies)`: creates custom theme runtime for on-disk theme discovery and JSON normalization/validation. +- Returned API: + - `normalizeThemeJson(raw)` + - `readCustomThemesFromDisk()` + +## Public exports (project-directory-runtime.js) +- `createProjectDirectoryRuntime(dependencies)`: creates runtime for request/project directory candidate normalization and validation. +- Returned API: + - `resolveDirectoryCandidate(value)` + - `validateDirectoryPath(candidate)` + - `resolveProjectDirectory(req)` + - `resolveOptionalProjectDirectory(req)` + +## Public exports (config-entity-routes.js) +- `registerConfigEntityRoutes(app, dependencies)`: registers configuration entity routes: + - Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config` + - Commands: `/api/config/commands/:name` + - MCP servers: `/api/config/mcp` and `/api/config/mcp/:name` + +## Public exports (auth-state-runtime.js) +- `createOpenCodeAuthStateRuntime(dependencies)`: creates runtime for managed OpenCode auth password state and request headers. +- Returned API: + - `getOpenCodeAuthHeaders()` + - `isOpenCodeConnectionSecure()` + - `ensureLocalOpenCodeServerPassword(options?)` + +## Public exports (core-routes.js) +- `registerServerStatusRoutes(app, dependencies)`: registers status/system endpoints: + - `GET /health` + - `POST /api/system/shutdown` + - `GET /api/system/info` +- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware: + - `GET /auth/session` + - `POST /auth/session` + - `GET /connect` + - `app.use('/api', ...)` auth/tunnel guard +- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints: + - `GET /api/config/themes` + - `POST /api/config/reload` +- `registerCommonRequestMiddleware(app, dependencies)`: registers shared request middleware stack: + - conditional JSON body parser behavior for `/api/*` vs non-API requests + - URL-encoded parser setup + - request logging middleware + +## Public exports (cli-options.js) +- `parseServeCliOptions(options)`: parses serve CLI flags and environment-derived defaults: + - Port/host/ui-password + - Tunnel provider/mode/config/token/hostname + - Legacy `--tunnel` shorthand normalization + +## Public exports (cli-entry-runtime.js) +- `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options. + +## Public exports (server-utils-runtime.js) +- `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers. +- Returned API: + - `setOpenCodePort(port)` + - `waitForOpenCodePort(timeoutMs?)` + - `buildAugmentedPath()` + - `parseSseDataPayload(block)` + - `fetchAgentsSnapshot()` + - `fetchProvidersSnapshot()` + - `fetchModelsSnapshot()` + - `setupProxy(app)` + +## Public exports (shutdown-runtime.js) +- `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing. +- Returned API: + - `gracefulShutdown(options?)` + +## Public exports (server-startup-runtime.js) +- `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring. +- Returned API: + - `resolveBindHost(host)` + - `startListeningAndMaybeTunnel(options)` + - `attachProcessHandlers(options)` + +## Public exports (static-routes-runtime.js) +- `createStaticRoutesRuntime(dependencies)`: creates runtime for static dist resolution and static route registration. +- Returned API: + - `registerStaticRoutes(app)` + +## Public exports (feature-routes-runtime.js) +- `createFeatureRoutesRuntime(dependencies)`: creates runtime for main feature route registration orchestration. +- Returned API: + - `registerRoutes(app, routeDependencies)` + +## Public exports (opencode-resolution-runtime.js) +- `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution. +- Returned API: + - `getOpenCodeResolutionSnapshot(settings)` + +## Public exports (tunnel-wiring-runtime.js) +- `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration. +- Returned API: + - `initialize(app, initialPort)` + +## Public exports (startup-pipeline-runtime.js) +- `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow. +- Returned API: + - `run(options)` + +## Public exports (openchamber-routes.js) +- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints: + - `GET /api/openchamber/update-check` + - `POST /api/openchamber/update-install` + - `GET /api/openchamber/models-metadata` + - `GET /api/zen/models` + +## Public exports (pwa-manifest-routes.js) +- `registerPwaManifestRoute(app, dependencies)`: registers PWA manifest endpoint with dynamic app-name resolution and recent-session shortcuts: + - `GET /manifest.webmanifest` + +## Public exports (project-icon-routes.js) +- `registerProjectIconRoutes(app, dependencies)`: registers project icon routes and owns icon storage/discovery flow: + - `GET /api/projects/:projectId/icon` + - `PUT /api/projects/:projectId/icon` + - `DELETE /api/projects/:projectId/icon` + - `POST /api/projects/:projectId/icon/discover` + +## Public exports (skill-routes.js) +- `registerSkillRoutes(app, dependencies)`: registers skills-related routes: + - Skills config CRUD and metadata under `/api/config/skills*` + - Skills catalog listing/source pagination, scan, and install routes + - Supporting skill file read/write/delete routes + +## Public exports (proxy.js) +- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware. +- Owns: + - SSE forwarders: `GET /api/global/event`, `GET /api/event` + - Session message forwarder: `POST /api/session/:sessionId/message` + - Generic `/api/*` forwarding with hop-by-hop header filtering + - Windows `/session` merge fallback path behavior + - OpenCode readiness gate for proxied `/api` requests + +## Public exports (watcher.js) +- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime. +- Returned API: + - `start()` + - `stop()` + ## Storage and configuration - Provider auth: `~/.local/share/opencode/auth.json`. - User config: `~/.config/opencode/opencode.json`. @@ -52,7 +348,7 @@ This module provides OpenCode server integration utilities for the web server ru ## Notes for contributors - This module serves as foundation for OpenCode-related server utilities. -- Index.js is currently a baseline placeholder; direct imports use submodule paths. +- Route ownership moved to module-level `routes.js`; `index.js` wires dependencies only. - All file writes include automatic backup before modification. - Config merging follows priority: custom > project > user. - UI auth uses scrypt for password hashing with constant-time comparison. diff --git a/packages/web/server/lib/opencode/auth-state-runtime.js b/packages/web/server/lib/opencode/auth-state-runtime.js new file mode 100644 index 00000000..9c8ce9fc --- /dev/null +++ b/packages/web/server/lib/opencode/auth-state-runtime.js @@ -0,0 +1,88 @@ +export const createOpenCodeAuthStateRuntime = (dependencies) => { + const { + crypto, + process, + getAuthPassword, + setAuthPassword, + getAuthSource, + setAuthSource, + getUserProvidedPassword, + syncToHmrState, + } = dependencies; + + const normalizeOpenCodePassword = (value) => { + if (typeof value !== 'string') { + return ''; + } + return value.trim(); + }; + + const isValidOpenCodePassword = (password) => typeof password === 'string' && password.trim().length > 0; + + const generateSecureOpenCodePassword = () => + crypto + .randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + const setOpenCodeAuthState = (password, source) => { + const normalized = normalizeOpenCodePassword(password); + if (!isValidOpenCodePassword(normalized)) { + setAuthPassword(null); + setAuthSource(null); + delete process.env.OPENCODE_SERVER_PASSWORD; + syncToHmrState(); + return null; + } + + setAuthPassword(normalized); + setAuthSource(source); + process.env.OPENCODE_SERVER_PASSWORD = normalized; + syncToHmrState(); + return normalized; + }; + + const getOpenCodeAuthHeaders = () => { + const password = normalizeOpenCodePassword(getAuthPassword() || process.env.OPENCODE_SERVER_PASSWORD || ''); + + if (!password) { + return {}; + } + + const credentials = Buffer.from(`opencode:${password}`).toString('base64'); + return { Authorization: `Basic ${credentials}` }; + }; + + const isOpenCodeConnectionSecure = () => Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization'); + + const ensureLocalOpenCodeServerPassword = async ({ rotateManaged = false } = {}) => { + const userProvidedPassword = getUserProvidedPassword(); + if (isValidOpenCodePassword(userProvidedPassword)) { + return setOpenCodeAuthState(userProvidedPassword, 'user-env'); + } + + if (rotateManaged) { + const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated'); + console.log('Rotated secure password for managed local OpenCode instance'); + return rotatedPassword; + } + + const currentPassword = getAuthPassword(); + const currentSource = getAuthSource(); + if (isValidOpenCodePassword(currentPassword)) { + return setOpenCodeAuthState(currentPassword, currentSource || 'generated'); + } + + const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated'); + console.log('Generated secure password for managed local OpenCode instance'); + return generatedPassword; + }; + + return { + getOpenCodeAuthHeaders, + isOpenCodeConnectionSecure, + ensureLocalOpenCodeServerPassword, + }; +}; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js new file mode 100644 index 00000000..30fc693e --- /dev/null +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -0,0 +1,119 @@ +export const createBootstrapRuntime = (dependencies) => { + const { + createUiAuth, + registerServerStatusRoutes, + registerCommonRequestMiddleware, + registerAuthAndAccessRoutes, + registerTtsRoutes, + registerNotificationRoutes, + registerOpenChamberRoutes, + express, + } = dependencies; + + const setupBaseRoutes = (app, options) => { + const { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + uiPassword, + tunnelAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + resolveZenModel, + sayTTSCapability, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + sessionRuntime, + setPushInitialized, + fs, + os, + path, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + fetchFreeZenModels, + getCachedZenModels, + } = options; + + registerServerStatusRoutes(app, { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + }); + + registerCommonRequestMiddleware(app, { express }); + + const uiAuthController = createUiAuth({ password: uiPassword }); + if (uiAuthController.enabled) { + console.log('UI password protection enabled for browser sessions'); + } + + registerAuthAndAccessRoutes(app, { + tunnelAuthController, + uiAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + }); + + registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }); + + registerNotificationRoutes(app, { + uiAuthController, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot, + getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot, + getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot, + getSessionState: sessionRuntime.getSessionState, + getSessionAttentionState: sessionRuntime.getSessionAttentionState, + markSessionViewed: sessionRuntime.markSessionViewed, + markSessionUnviewed: sessionRuntime.markSessionUnviewed, + markUserMessageSent: sessionRuntime.markUserMessageSent, + setPushInitialized, + }); + + registerOpenChamberRoutes(app, { + fs, + os, + path, + process, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + readSettingsFromDiskMigrated, + fetchFreeZenModels, + getCachedZenModels, + }); + + return { + uiAuthController, + }; + }; + + return { + setupBaseRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/cli-entry-runtime.js b/packages/web/server/lib/opencode/cli-entry-runtime.js new file mode 100644 index 00000000..89f5f719 --- /dev/null +++ b/packages/web/server/lib/opencode/cli-entry-runtime.js @@ -0,0 +1,43 @@ +export const runCliEntryIfMain = (dependencies) => { + const { + process, + currentFilename, + parseServeCliOptions, + defaultPort, + cloudflareProvider, + managedLocalMode, + setExitOnShutdown, + startServer, + } = dependencies; + + const isCliExecution = process.argv[1] === currentFilename; + if (!isCliExecution) { + return; + } + + const cliOptions = parseServeCliOptions({ + argv: process.argv.slice(2), + env: process.env, + defaultPort, + cloudflareProvider, + managedLocalMode, + }); + + setExitOnShutdown(true); + startServer({ + port: cliOptions.port, + host: cliOptions.host, + tryCfTunnel: cliOptions.tryCfTunnel, + tunnelProvider: cliOptions.tunnelProvider, + tunnelMode: cliOptions.tunnelMode, + tunnelConfigPath: cliOptions.tunnelConfigPath, + tunnelToken: cliOptions.tunnelToken, + tunnelHostname: cliOptions.tunnelHostname, + attachSignals: true, + exitOnShutdown: true, + uiPassword: cliOptions.uiPassword, + }).catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); + }); +}; diff --git a/packages/web/server/lib/opencode/cli-options.js b/packages/web/server/lib/opencode/cli-options.js new file mode 100644 index 00000000..2ded944e --- /dev/null +++ b/packages/web/server/lib/opencode/cli-options.js @@ -0,0 +1,128 @@ +export const parseServeCliOptions = ({ + argv = [], + env = {}, + defaultPort, + cloudflareProvider, + managedLocalMode, +}) => { + const args = Array.isArray(argv) ? [...argv] : []; + const envPassword = + env.OPENCHAMBER_UI_PASSWORD || + env.OPENCODE_UI_PASSWORD || + null; + const envCfTunnel = env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; + const envTunnelProvider = env.OPENCHAMBER_TUNNEL_PROVIDER || undefined; + const envTunnelMode = env.OPENCHAMBER_TUNNEL_MODE || undefined; + const envTunnelConfigRaw = env.OPENCHAMBER_TUNNEL_CONFIG; + const envTunnelConfig = typeof envTunnelConfigRaw === 'string' + ? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null) + : undefined; + const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined; + const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined; + + const options = { + port: defaultPort, + host: undefined, + uiPassword: envPassword, + tryCfTunnel: envCfTunnel, + tunnelProvider: envTunnelProvider, + tunnelMode: envTunnelMode, + tunnelConfigPath: envTunnelConfig, + tunnelToken: envTunnelToken, + tunnelHostname: envTunnelHostname, + }; + + const consumeValue = (currentIndex, inlineValue) => { + if (typeof inlineValue === 'string') { + return { value: inlineValue, nextIndex: currentIndex }; + } + const nextArg = args[currentIndex + 1]; + if (typeof nextArg === 'string' && !nextArg.startsWith('--')) { + return { value: nextArg, nextIndex: currentIndex + 1 }; + } + return { value: undefined, nextIndex: currentIndex }; + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith('--')) { + continue; + } + + const eqIndex = arg.indexOf('='); + const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2); + const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined; + + if (optionName === 'port' || optionName === 'p') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + const parsedPort = parseInt(value ?? '', 10); + options.port = Number.isFinite(parsedPort) ? parsedPort : defaultPort; + continue; + } + + if (optionName === 'host') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + continue; + } + + if (optionName === 'ui-password') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.uiPassword = typeof value === 'string' ? value : ''; + continue; + } + + if (optionName === 'try-cf-tunnel') { + options.tryCfTunnel = true; + continue; + } + + if (optionName === 'tunnel-provider') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider; + continue; + } + + if (optionName === 'tunnel-mode') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode; + continue; + } + + if (optionName === 'tunnel-config') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelConfigPath = typeof value === 'string' ? value : null; + continue; + } + + if (optionName === 'tunnel-token') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken; + continue; + } + + if (optionName === 'tunnel-hostname') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname; + continue; + } + + if (optionName === 'tunnel') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelProvider = cloudflareProvider; + options.tunnelMode = managedLocalMode; + options.tunnelConfigPath = typeof value === 'string' ? value : null; + } + } + + return options; +}; diff --git a/packages/web/server/lib/opencode/config-entity-routes.js b/packages/web/server/lib/opencode/config-entity-routes.js new file mode 100644 index 00000000..3dadcd62 --- /dev/null +++ b/packages/web/server/lib/opencode/config-entity-routes.js @@ -0,0 +1,362 @@ +export const registerConfigEntityRoutes = (app, dependencies) => { + const { + resolveProjectDirectory, + resolveOptionalProjectDirectory, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + } = dependencies; + + app.get('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getAgentSources(agentName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + + res.json({ + name: agentName, + sources: sources, + scope, + isBuiltIn: !sources.md.exists && !sources.json.exists + }); + } catch (error) { + console.error('Failed to get agent sources:', error); + res.status(500).json({ error: 'Failed to get agent configuration metadata' }); + } + }); + + app.get('/api/config/agents/:name/config', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const configInfo = getAgentConfig(agentName, directory); + res.json(configInfo); + } catch (error) { + console.error('Failed to get agent config:', error); + res.status(500).json({ error: 'Failed to get agent configuration' }); + } + }); + + app.post('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { scope, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating agent:', agentName); + console.log('[Server] Config received:', JSON.stringify(config, null, 2)); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createAgent(agentName, config, directory, scope); + await refreshOpenCodeAfterConfigChange('agent creation', { + agentName + }); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create agent:', error); + res.status(500).json({ error: error.message || 'Failed to create agent' }); + } + }); + + app.patch('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating agent: ${agentName}`); + console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); + console.log('[Server] Working directory:', directory); + + updateAgent(agentName, updates, directory); + await refreshOpenCodeAfterConfigChange('agent update'); + + console.log(`[Server] Agent ${agentName} updated successfully`); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update agent:', error); + console.error('[Server] Error stack:', error.stack); + res.status(500).json({ error: error.message || 'Failed to update agent' }); + } + }); + + app.delete('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteAgent(agentName, directory); + await refreshOpenCodeAfterConfigChange('agent deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete agent:', error); + res.status(500).json({ error: error.message || 'Failed to delete agent' }); + } + }); + + app.get('/api/config/mcp', async (req, res) => { + try { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + const configs = listMcpConfigs(directory); + res.json(configs); + } catch (error) { + console.error('[API:GET /api/config/mcp] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to list MCP configs' }); + } + }); + + app.get('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + const config = getMcpConfig(name, directory); + if (!config) { + return res.status(404).json({ error: `MCP server "${name}" not found` }); + } + res.json(config); + } catch (error) { + console.error('[API:GET /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to get MCP config' }); + } + }); + + app.post('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { scope, ...config } = req.body || {}; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`); + + createMcpConfig(name, config, directory, scope); + await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name }); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" created. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:POST /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to create MCP server' }); + } + }); + + app.patch('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const updates = req.body; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`); + + updateMcpConfig(name, updates, directory); + await refreshOpenCodeAfterConfigChange('mcp update'); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" updated. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:PATCH /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to update MCP server' }); + } + }); + + app.delete('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`); + + deleteMcpConfig(name, directory); + await refreshOpenCodeAfterConfigChange('mcp deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" deleted. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:DELETE /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to delete MCP server' }); + } + }); + + app.get('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getCommandSources(commandName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + + res.json({ + name: commandName, + sources: sources, + scope, + isBuiltIn: !sources.md.exists && !sources.json.exists + }); + } catch (error) { + console.error('Failed to get command sources:', error); + res.status(500).json({ error: 'Failed to get command configuration metadata' }); + } + }); + + app.post('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { scope, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating command:', commandName); + console.log('[Server] Config received:', JSON.stringify(config, null, 2)); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createCommand(commandName, config, directory, scope); + await refreshOpenCodeAfterConfigChange('command creation', { + commandName + }); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create command:', error); + res.status(500).json({ error: error.message || 'Failed to create command' }); + } + }); + + app.patch('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating command: ${commandName}`); + console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); + console.log('[Server] Working directory:', directory); + + updateCommand(commandName, updates, directory); + await refreshOpenCodeAfterConfigChange('command update'); + + console.log(`[Server] Command ${commandName} updated successfully`); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update command:', error); + console.error('[Server] Error stack:', error.stack); + res.status(500).json({ error: error.message || 'Failed to update command' }); + } + }); + + app.delete('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteCommand(commandName, directory); + await refreshOpenCodeAfterConfigChange('command deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete command:', error); + res.status(500).json({ error: error.message || 'Failed to delete command' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js new file mode 100644 index 00000000..9454a54f --- /dev/null +++ b/packages/web/server/lib/opencode/core-routes.js @@ -0,0 +1,186 @@ +export const registerServerStatusRoutes = (app, dependencies) => { + const { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + } = dependencies; + + app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + ...getHealthSnapshot(), + }); + }); + + app.post('/api/system/shutdown', (_req, res) => { + res.json({ ok: true }); + gracefulShutdown({ exitProcess: false }).catch((error) => { + console.error('Shutdown request failed:', error?.message || error); + }); + }); + + app.get('/api/system/info', (_req, res) => { + res.json({ + openchamberVersion, + runtime: runtimeName, + pid: process.pid, + startedAt: serverStartedAt, + }); + }); +}; + +export const registerAuthAndAccessRoutes = (app, dependencies) => { + const { + tunnelAuthController, + uiAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + } = dependencies; + + app.get('/auth/session', async (req, res) => { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req); + if (tunnelSession) { + return res.json({ authenticated: true, scope: 'tunnel' }); + } + tunnelAuthController.clearTunnelSessionCookie(req, res); + return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true }); + } + + try { + await uiAuthController.handleSessionStatus(req, res); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } + }); + + app.post('/auth/session', (req, res) => { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true }); + } + return uiAuthController.handleSessionCreate(req, res); + }); + + app.get('/connect', async (req, res) => { + try { + const token = typeof req.query?.t === 'string' ? req.query.t : ''; + const settings = await readSettingsFromDiskMigrated(); + const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + + const exchange = tunnelAuthController.exchangeBootstrapToken({ + req, + res, + token, + sessionTtlMs: tunnelSessionTtlMs, + }); + + res.setHeader('Cache-Control', 'no-store'); + + if (!exchange.ok) { + if (exchange.reason === 'rate-limited') { + res.setHeader('Retry-After', String(exchange.retryAfter || 60)); + return res.status(429).type('text/plain').send('Too many attempts. Please try again later.'); + } + return res.status(401).type('text/plain').send('Connection link is invalid or expired.'); + } + + return res.redirect(302, '/'); + } catch { + return res.status(500).type('text/plain').send('Failed to process connect request.'); + } + }); + + app.use('/api', async (req, res, next) => { + try { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return tunnelAuthController.requireTunnelSession(req, res, next); + } + await uiAuthController.requireAuth(req, res, next); + } catch (err) { + next(err); + } + }); +}; + +export const registerSettingsUtilityRoutes = (app, dependencies) => { + const { + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + } = dependencies; + + app.get('/api/config/themes', async (_req, res) => { + try { + const customThemes = await readCustomThemesFromDisk(); + res.json({ themes: customThemes }); + } catch (error) { + console.error('Failed to load custom themes:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' }); + } + }); + + app.post('/api/config/reload', async (_req, res) => { + try { + console.log('[Server] Manual configuration reload requested'); + + await refreshOpenCodeAfterConfigChange('manual configuration reload'); + + res.json({ + success: true, + requiresReload: true, + message: 'Configuration reloaded successfully. Refreshing interface…', + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to reload configuration:', error); + res.status(500).json({ + error: error.message || 'Failed to reload configuration', + success: false, + }); + } + }); +}; + +export const registerCommonRequestMiddleware = (app, dependencies) => { + const { express } = dependencies; + + app.use((req, res, next) => { + if ( + req.path.startsWith('/api/config/agents') || + req.path.startsWith('/api/config/commands') || + req.path.startsWith('/api/config/mcp') || + req.path.startsWith('/api/config/settings') || + req.path.startsWith('/api/config/skills') || + req.path.startsWith('/api/projects') || + req.path.startsWith('/api/fs') || + req.path.startsWith('/api/git') || + req.path.startsWith('/api/prompts') || + req.path.startsWith('/api/terminal') || + req.path.startsWith('/api/opencode') || + req.path.startsWith('/api/push') || + req.path.startsWith('/api/voice') || + req.path.startsWith('/api/tts') || + req.path.startsWith('/api/openchamber/tunnel') + ) { + express.json({ limit: '50mb' })(req, res, next); + } else if (req.path.startsWith('/api')) { + next(); + } else { + express.json({ limit: '50mb' })(req, res, next); + } + }); + + app.use(express.urlencoded({ extended: true, limit: '50mb' })); + + app.use((req, _res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); + }); +}; diff --git a/packages/web/server/lib/opencode/env-config.js b/packages/web/server/lib/opencode/env-config.js new file mode 100644 index 00000000..fc493f0b --- /dev/null +++ b/packages/web/server/lib/opencode/env-config.js @@ -0,0 +1,72 @@ +export const resolveOpenCodeEnvConfig = (options = {}) => { + const env = options.env && typeof options.env === 'object' ? options.env : {}; + const logger = options.logger ?? console; + + const configuredOpenCodePort = (() => { + const raw = + env.OPENCODE_PORT || + env.OPENCHAMBER_OPENCODE_PORT || + env.OPENCHAMBER_INTERNAL_PORT; + if (!raw) { + return null; + } + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + })(); + + const configuredOpenCodeHost = (() => { + const raw = typeof env.OPENCODE_HOST === 'string' ? env.OPENCODE_HOST.trim() : ''; + if (!raw) return null; + + const warnInvalidHost = (reason) => { + logger.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`); + }; + + let url; + try { + url = new URL(raw); + } catch { + warnInvalidHost('not a valid URL'); + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`); + return null; + } + const port = parseInt(url.port, 10); + if (!Number.isFinite(port) || port <= 0) { + warnInvalidHost('must include an explicit port (example: http://hostname:4096)'); + return null; + } + if (url.pathname !== '/' || url.search || url.hash) { + warnInvalidHost('must not include path, query, or hash'); + return null; + } + return { origin: url.origin, port }; + })(); + + // OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set + const effectivePort = configuredOpenCodeHost?.port ?? configuredOpenCodePort; + + const configuredOpenCodeHostname = (() => { + const raw = env.OPENCHAMBER_OPENCODE_HOSTNAME; + if (typeof raw !== 'string') { + return '127.0.0.1'; + } + const trimmed = raw.trim(); + if (!trimmed) { + logger.warn( + `[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`, + ); + return '127.0.0.1'; + } + return trimmed; + })(); + + return { + configuredOpenCodePort, + configuredOpenCodeHost, + effectivePort, + configuredOpenCodeHostname, + }; +}; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js new file mode 100644 index 00000000..0d1abdf5 --- /dev/null +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -0,0 +1,908 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const createOpenCodeEnvRuntime = (deps) => { + const { + state, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + } = deps; + + const parseNullSeparatedEnvSnapshot = (raw) => { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + const result = {}; + const entries = raw.split('\0'); + for (const entry of entries) { + if (!entry) { + continue; + } + const idx = entry.indexOf('='); + if (idx <= 0) { + continue; + } + const key = entry.slice(0, idx); + const value = entry.slice(idx + 1); + result[key] = value; + } + + return Object.keys(result).length > 0 ? result : null; + }; + + const isExecutable = (filePath) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } + }; + + const searchPathFor = (binaryName) => { + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + for (const dir of parts) { + const candidate = path.join(dir, binaryName); + if (isExecutable(candidate)) { + return candidate; + } + } + return null; + }; + + const prependToPath = (dir) => { + const trimmed = typeof dir === 'string' ? dir.trim() : ''; + if (!trimmed) return; + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + if (parts.includes(trimmed)) return; + process.env.PATH = [trimmed, ...parts].join(path.delimiter); + }; + + const getWindowsShellEnvSnapshot = () => { + const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : ''); + + const psScript = + "Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }"; + + const powershellCandidates = [ + 'pwsh.exe', + 'powershell.exe', + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]; + + for (const shellPath of powershellCandidates) { + try { + const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + if (result.status !== 0) { + continue; + } + const parsed = parseResult(result.stdout); + if (parsed) { + return parsed; + } + } catch { + } + } + + const comspec = process.env.ComSpec || 'cmd.exe'; + try { + const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) { + return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0')); + } + } catch { + } + + return null; + }; + + const getLoginShellEnvSnapshot = () => { + if (state.cachedLoginShellEnvSnapshot !== undefined) { + return state.cachedLoginShellEnvSnapshot; + } + + if (process.platform === 'win32') { + const windowsSnapshot = getWindowsShellEnvSnapshot(); + state.cachedLoginShellEnvSnapshot = windowsSnapshot; + return windowsSnapshot; + } + + const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + + for (const shellPath of shellCandidates) { + if (!isExecutable(shellPath)) { + continue; + } + + try { + const result = spawnSync(shellPath, ['-lic', 'env -0'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + + if (result.status !== 0) { + continue; + } + + const parsed = parseNullSeparatedEnvSnapshot(result.stdout || ''); + if (parsed) { + state.cachedLoginShellEnvSnapshot = parsed; + return parsed; + } + } catch { + } + } + + state.cachedLoginShellEnvSnapshot = null; + return null; + }; + + const mergePathValues = (preferred, fallback) => { + const merged = new Set(); + + const addSegments = (value) => { + if (typeof value !== 'string' || !value) { + return; + } + for (const segment of value.split(path.delimiter)) { + if (segment) { + merged.add(segment); + } + } + }; + + addSegments(preferred); + addSegments(fallback); + + return Array.from(merged).join(path.delimiter); + }; + + const applyLoginShellEnvSnapshot = () => { + const snapshot = getLoginShellEnvSnapshot(); + if (!snapshot) { + return; + } + + const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); + for (const [key, value] of Object.entries(snapshot)) { + if (skipKeys.has(key)) { + continue; + } + const existing = process.env[key]; + if (typeof existing === 'string' && existing.length > 0) { + continue; + } + process.env[key] = value; + } + + process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || ''); + }; + + const isWslExecutableValue = (value) => { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed) return false; + return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); + }; + + const clearWslOpencodeResolution = () => { + state.useWslForOpencode = false; + state.resolvedWslBinary = null; + state.resolvedWslOpencodePath = null; + state.resolvedWslDistro = null; + }; + + const resolveWslExecutablePath = () => { + if (process.platform !== 'win32') { + return null; + } + + const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + try { + const result = spawnSync('where', ['wsl'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) { + return found; + } + } + } catch { + } + + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + const fallback = path.join(systemRoot, 'System32', 'wsl.exe'); + if (isExecutable(fallback)) { + return fallback; + } + + return null; + }; + + const buildWslExecArgs = (execArgs, distroOverride = null) => { + const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0 + ? distroOverride.trim() + : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + + const prefix = distro ? ['-d', distro] : []; + return [...prefix, '--exec', ...execArgs]; + }; + + const probeWslForOpencode = () => { + if (process.platform !== 'win32') { + return null; + } + + const wslBinary = resolveWslExecutablePath(); + if (!wslBinary) { + return null; + } + + try { + const result = spawnSync( + wslBinary, + buildWslExecArgs(['sh', '-lc', 'command -v opencode']), + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 6000, + windowsHide: true, + } + ); + + if (result.status !== 0) { + return null; + } + + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines[0] || ''; + if (!found) { + return null; + } + + return { + wslBinary, + opencodePath: found, + distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }; + } catch { + return null; + } + }; + + const applyWslOpencodeResolution = ({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) => { + const resolvedWsl = wslBinary || resolveWslExecutablePath(); + if (!resolvedWsl) { + return null; + } + + state.useWslForOpencode = true; + state.resolvedWslBinary = resolvedWsl; + state.resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0 + ? opencodePath.trim() + : 'opencode'; + state.resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + state.resolvedOpencodeBinary = `wsl:${state.resolvedWslOpencodePath}`; + state.resolvedOpencodeBinarySource = source; + + delete process.env.OPENCODE_BINARY; + return state.resolvedOpencodeBinary; + }; + + const resolveOpencodeCliPath = () => { + const explicit = [ + process.env.OPENCODE_BINARY, + process.env.OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_BIN, + ] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'env'; + return candidate; + } + } + + const resolvedFromPath = searchPathFor('opencode'); + if (resolvedFromPath) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'path'; + return resolvedFromPath; + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.bun', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + '/bin/opencode', + ]; + + const winFallbacks = (() => { + const userProfile = process.env.USERPROFILE || home; + const appData = process.env.APPDATA || ''; + const localAppData = process.env.LOCALAPPDATA || ''; + const programData = process.env.ProgramData || 'C:\\ProgramData'; + + return [ + path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), + path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), + path.join(appData, 'npm', 'opencode.cmd'), + path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'), + path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), + path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), + path.join(userProfile, '.bun', 'bin', 'opencode.exe'), + path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), + localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', + ].filter(Boolean); + })(); + + const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; + for (const candidate of fallbacks) { + if (isExecutable(candidate)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'fallback'; + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'where'; + return found; + } + } + } catch { + } + const wsl = probeWslForOpencode(); + if (wsl) { + return applyWslOpencodeResolution({ + wslBinary: wsl.wslBinary, + opencodePath: wsl.opencodePath, + source: 'wsl', + distro: wsl.distro, + }); + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'shell'; + return found; + } + } + } catch { + } + } + + return null; + }; + + const resolveNodeCliPath = () => { + const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('node'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const unixFallbacks = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/bin/node']; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + } + } + + return null; + }; + + const resolveBunCliPath = () => { + const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('bun'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.bun', 'bin', 'bun'), + '/opt/homebrew/bin/bun', + '/usr/local/bin/bun', + '/usr/bin/bun', + '/bin/bun', + ]; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + const userProfile = process.env.USERPROFILE || home; + const winFallbacks = [ + path.join(userProfile, '.bun', 'bin', 'bun.exe'), + path.join(userProfile, '.bun', 'bin', 'bun.cmd'), + ]; + for (const candidate of winFallbacks) { + if (isExecutable(candidate)) return candidate; + } + + try { + const result = spawnSync('where', ['bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + } + } + + return null; + }; + + const ensureBunCliEnv = () => { + if (state.resolvedBunBinary) { + return state.resolvedBunBinary; + } + + const resolved = resolveBunCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + state.resolvedBunBinary = resolved; + return resolved; + } + + return null; + }; + + const ensureNodeCliEnv = () => { + if (state.resolvedNodeBinary) { + return state.resolvedNodeBinary; + } + + const resolved = resolveNodeCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + state.resolvedNodeBinary = resolved; + return resolved; + } + + return null; + }; + + const readShebang = (opencodePath) => { + if (!opencodePath || typeof opencodePath !== 'string') { + return null; + } + try { + const fd = fs.openSync(opencodePath, 'r'); + try { + const buf = Buffer.alloc(256); + const bytes = fs.readSync(fd, buf, 0, buf.length, 0); + const head = buf.subarray(0, bytes).toString('utf8'); + const firstLine = head.split(/\r?\n/, 1)[0] || ''; + if (!firstLine.startsWith('#!')) { + return null; + } + const shebang = firstLine.slice(2).trim(); + if (!shebang) { + return null; + } + return shebang; + } finally { + try { + fs.closeSync(fd); + } catch { + } + } + } catch { + return null; + } + }; + + const opencodeShimInterpreter = (opencodePath) => { + const shebang = readShebang(opencodePath); + if (!shebang) return null; + if (/\bnode\b/i.test(shebang)) return 'node'; + if (/\bbun\b/i.test(shebang)) return 'bun'; + return null; + }; + + const ensureOpencodeShimRuntime = (opencodePath) => { + const runtime = opencodeShimInterpreter(opencodePath); + if (runtime === 'node') { + ensureNodeCliEnv(); + } + if (runtime === 'bun') { + ensureBunCliEnv(); + } + }; + + const normalizeOpencodeBinarySetting = (raw) => { + if (typeof raw !== 'string') { + return null; + } + const trimmed = normalizeDirectoryPath(raw).trim(); + if (!trimmed) { + return ''; + } + + try { + const stat = fs.statSync(trimmed); + if (stat.isDirectory()) { + const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + return path.join(trimmed, bin); + } + } catch { + } + + return trimmed; + }; + + const applyOpencodeBinaryFromSettings = async () => { + try { + const settings = await readSettingsFromDiskMigrated(); + if (!settings || typeof settings !== 'object') { + return null; + } + if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { + return null; + } + + const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); + + if (normalized === '') { + delete process.env.OPENCODE_BINARY; + state.resolvedOpencodeBinary = null; + state.resolvedOpencodeBinarySource = null; + clearWslOpencodeResolution(); + return null; + } + + const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; + const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' + ? raw.match(/^wsl:\s*(.+)$/i) + : null; + + if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || resolveWslExecutablePath(), + opencodePath: explicitWslPath[1].trim(), + source: 'settings-wsl-path', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + + if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || normalized || raw || null, + opencodePath: probe?.opencodePath || 'opencode', + source: 'settings-wsl', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + + if (normalized && isExecutable(normalized)) { + clearWslOpencodeResolution(); + process.env.OPENCODE_BINARY = normalized; + prependToPath(path.dirname(normalized)); + state.resolvedOpencodeBinary = normalized; + state.resolvedOpencodeBinarySource = 'settings'; + ensureOpencodeShimRuntime(normalized); + return normalized; + } + + if (raw) { + console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); + } + } catch { + } + + return null; + }; + + const ensureOpencodeCliEnv = () => { + if (state.resolvedOpencodeBinary) { + if (state.useWslForOpencode) { + return state.resolvedOpencodeBinary; + } + ensureOpencodeShimRuntime(state.resolvedOpencodeBinary); + return state.resolvedOpencodeBinary; + } + + const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; + if (existing && isExecutable(existing)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinary = existing; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'env'; + prependToPath(path.dirname(existing)); + ensureOpencodeShimRuntime(existing); + return state.resolvedOpencodeBinary; + } + + const resolved = resolveOpencodeCliPath(); + if (resolved) { + if (state.useWslForOpencode) { + state.resolvedOpencodeBinary = resolved; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'wsl'; + console.log(`Resolved opencode CLI via WSL: ${state.resolvedWslOpencodePath || 'opencode'}`); + return resolved; + } + + process.env.OPENCODE_BINARY = resolved; + prependToPath(path.dirname(resolved)); + ensureOpencodeShimRuntime(resolved); + state.resolvedOpencodeBinary = resolved; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'unknown'; + console.log(`Resolved opencode CLI: ${resolved}`); + return resolved; + } + + clearWslOpencodeResolution(); + return null; + }; + + const resolveGitBinaryForSpawn = () => { + if (process.platform !== 'win32') { + return 'git'; + } + + if (state.resolvedGitBinary) { + return state.resolvedGitBinary; + } + + const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + for (const candidate of explicit) { + if (isExecutable(candidate)) { + state.resolvedGitBinary = candidate; + return state.resolvedGitBinary; + } + } + + const candidates = []; + const normalizeGitCandidate = (candidate) => { + if (typeof candidate !== 'string') { + return ''; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ''; + } + const ext = path.extname(trimmed).toLowerCase(); + if (ext === '.cmd' || ext === '.bat' || ext === '.com') { + const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; + if (isExecutable(exeCandidate)) { + return exeCandidate; + } + } + return trimmed; + }; + + const pathCandidate = normalizeGitCandidate(searchPathFor('git')); + if (pathCandidate && isExecutable(pathCandidate)) { + candidates.push(pathCandidate); + } + + const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe')); + if (pathExeCandidate && isExecutable(pathExeCandidate)) { + candidates.push(pathExeCandidate); + } + + const programRoots = [ + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + process.env.LocalAppData, + ] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + for (const root of programRoots) { + const installCandidates = [ + path.join(root, 'Git', 'cmd', 'git.exe'), + path.join(root, 'Git', 'bin', 'git.exe'), + path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'), + path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'), + path.join(root, 'Programs', 'Git', 'bin', 'git.exe'), + ]; + for (const candidate of installCandidates) { + const normalized = normalizeGitCandidate(candidate); + if (normalized && isExecutable(normalized)) { + candidates.push(normalized); + } + } + } + + const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe')); + state.resolvedGitBinary = preferredExe || candidates[0] || 'git.exe'; + return state.resolvedGitBinary; + }; + + const clearResolvedOpenCodeBinary = () => { + state.resolvedOpencodeBinary = null; + }; + + return { + applyLoginShellEnvSnapshot, + ensureOpencodeCliEnv, + applyOpencodeBinaryFromSettings, + getLoginShellEnvSnapshot, + resolveOpencodeCliPath, + isExecutable, + searchPathFor, + resolveGitBinaryForSpawn, + resolveWslExecutablePath, + buildWslExecArgs, + opencodeShimInterpreter, + clearResolvedOpenCodeBinary, + }; +}; diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js new file mode 100644 index 00000000..a2c8cad3 --- /dev/null +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -0,0 +1,216 @@ +import { registerFsRoutes } from '../fs/routes.js'; +import { registerQuotaRoutes } from '../quota/routes.js'; +import { registerGitHubRoutes } from '../github/routes.js'; +import { registerGitRoutes } from '../git/routes.js'; +import { registerConfigEntityRoutes } from './config-entity-routes.js'; +import { registerSettingsUtilityRoutes } from './core-routes.js'; +import { registerProjectIconRoutes } from './project-icon-routes.js'; +import { registerSkillRoutes } from './skill-routes.js'; +import { registerOpenCodeRoutes } from './routes.js'; + +export const createFeatureRoutesRuntime = (dependencies) => { + const { + clientReloadDelayMs, + } = dependencies; + + let quotaProviders = null; + const getQuotaProviders = async () => { + if (!quotaProviders) { + quotaProviders = await import('../quota/index.js'); + } + return quotaProviders; + }; + + const registerRoutes = async (app, routeDependencies) => { + const { + crypto, + fs, + os, + path, + fsPromises, + spawn, + resolveGitBinaryForSpawn, + createFsSearchRuntime, + openchamberDataDir, + openchamberUserConfigRoot, + normalizeDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + validateDirectoryPath, + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + buildAugmentedPath, + } = routeDependencies; + + const { getProviderSources, removeProviderConfig } = await import('./index.js'); + + registerSettingsUtilityRoutes(app, { + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + }); + + registerOpenCodeRoutes(app, { + crypto, + clientReloadDelayMs, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + validateDirectoryPath, + resolveProjectDirectory, + getProviderSources, + removeProviderConfig, + refreshOpenCodeAfterConfigChange, + }); + + registerProjectIconRoutes(app, { + fsPromises, + path, + crypto, + openchamberDataDir, + sanitizeProjects, + readSettingsFromDiskMigrated, + persistSettings, + createFsSearchRuntime, + spawn, + resolveGitBinaryForSpawn, + }); + + const { + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + } = await import('./index.js'); + + registerConfigEntityRoutes(app, { + resolveProjectDirectory, + resolveOptionalProjectDirectory, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + }); + + const { + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + } = await import('./index.js'); + + const { + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + } = await import('../skills-catalog/index.js'); + const { getProfiles, getProfile } = await import('../git/index.js'); + + registerSkillRoutes(app, { + fs, + path, + os, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + readSettingsFromDisk, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + getProfiles, + getProfile, + }); + + registerQuotaRoutes(app, { getQuotaProviders }); + registerGitHubRoutes(app); + registerGitRoutes(app); + registerFsRoutes(app, { + os, + path, + fsPromises, + spawn, + crypto, + normalizeDirectoryPath, + resolveProjectDirectory, + buildAugmentedPath, + resolveGitBinaryForSpawn, + openchamberUserConfigRoot, + }); + }; + + return { + registerRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/hmr-state-runtime.js b/packages/web/server/lib/opencode/hmr-state-runtime.js new file mode 100644 index 00000000..c06da01c --- /dev/null +++ b/packages/web/server/lib/opencode/hmr-state-runtime.js @@ -0,0 +1,85 @@ +export const createHmrStateRuntime = (dependencies) => { + const { + globalThisLike, + os, + processLike, + stateKey, + } = dependencies; + + const getOrCreateHmrState = () => { + if (!globalThisLike[stateKey]) { + globalThisLike[stateKey] = { + openCodeProcess: null, + openCodePort: null, + openCodeWorkingDirectory: os.homedir(), + isShuttingDown: false, + signalsAttached: false, + userProvidedOpenCodePassword: undefined, + openCodeAuthPassword: null, + openCodeAuthSource: null, + }; + } + return globalThisLike[stateKey]; + }; + + const ensureUserProvidedOpenCodePassword = (hmrState) => { + if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') { + return; + } + const initialPassword = typeof processLike.env.OPENCODE_SERVER_PASSWORD === 'string' + ? processLike.env.OPENCODE_SERVER_PASSWORD.trim() + : ''; + hmrState.userProvidedOpenCodePassword = initialPassword || null; + }; + + const getUserProvidedOpenCodePassword = (hmrState) => ( + typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 + ? hmrState.userProvidedOpenCodePassword + : null + ); + + const resolveOpenCodeAuthFromState = ({ hmrState, userProvidedOpenCodePassword }) => ({ + openCodeAuthPassword: + typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 + ? hmrState.openCodeAuthPassword + : userProvidedOpenCodePassword, + openCodeAuthSource: + typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 + ? hmrState.openCodeAuthSource + : (userProvidedOpenCodePassword ? 'user-env' : null), + }); + + const syncStateFromRuntime = (hmrState, runtime) => { + hmrState.openCodeProcess = runtime.openCodeProcess; + hmrState.openCodePort = runtime.openCodePort; + hmrState.openCodeBaseUrl = runtime.openCodeBaseUrl; + hmrState.isShuttingDown = runtime.isShuttingDown; + hmrState.signalsAttached = runtime.signalsAttached; + hmrState.openCodeWorkingDirectory = runtime.openCodeWorkingDirectory; + hmrState.openCodeAuthPassword = runtime.openCodeAuthPassword; + hmrState.openCodeAuthSource = runtime.openCodeAuthSource; + }; + + const restoreRuntimeFromState = ({ hmrState, userProvidedOpenCodePassword }) => { + const auth = resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword }); + return { + openCodeProcess: hmrState.openCodeProcess, + openCodePort: hmrState.openCodePort, + openCodeBaseUrl: hmrState.openCodeBaseUrl ?? null, + isShuttingDown: hmrState.isShuttingDown, + signalsAttached: hmrState.signalsAttached, + openCodeWorkingDirectory: hmrState.openCodeWorkingDirectory, + openCodeAuthPassword: auth.openCodeAuthPassword, + openCodeAuthSource: auth.openCodeAuthSource, + }; + }; + + return { + getOrCreateHmrState, + ensureUserProvidedOpenCodePassword, + getUserProvidedOpenCodePassword, + resolveOpenCodeAuthFromState, + syncStateFromRuntime, + restoreRuntimeFromState, + }; +}; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js new file mode 100644 index 00000000..ece1826a --- /dev/null +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -0,0 +1,630 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +export const createOpenCodeLifecycleRuntime = (deps) => { + const { + state, + env, + syncToHmrState, + syncFromHmrState, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + waitForReady, + normalizeApiPrefix, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + ensureLocalOpenCodeServerPassword, + buildWslExecArgs, + resolveWslExecutablePath, + opencodeShimInterpreter, + setOpenCodePort, + setDetectedOpenCodeApiPrefix, + setupProxy, + ensureOpenCodeApiPrefix, + clearResolvedOpenCodeBinary, + } = deps; + + const killProcessOnPort = (port) => { + if (!port) return; + try { + const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); + const output = result.stdout || ''; + const myPid = process.pid; + for (const pidStr of output.split(/\s+/)) { + const pid = parseInt(pidStr.trim(), 10); + if (pid && pid !== myPid) { + try { + spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 }); + } catch { + } + } + } + } catch { + } + }; + + const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => { + let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + let args = ['serve', '--hostname', hostname, '--port', String(port)]; + + if (process.platform === 'win32' && state.useWslForOpencode) { + const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath(); + if (!wslBinary) { + throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); + } + + const wslOpencode = state.resolvedWslOpencodePath && state.resolvedWslOpencodePath.trim().length > 0 + ? state.resolvedWslOpencodePath.trim() + : 'opencode'; + const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; + + binary = wslBinary; + args = buildWslExecArgs([ + wslOpencode, + 'serve', + '--hostname', + serveHost, + '--port', + String(port), + ], state.resolvedWslDistro); + } + + if (process.platform === 'win32' && !state.useWslForOpencode) { + const interpreter = opencodeShimInterpreter(binary); + if (interpreter) { + args.unshift(binary); + binary = interpreter; + } else { + try { + const shimContent = fs.readFileSync(binary, 'utf8'); + const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); + if (jsMatch) { + const candidate = path.resolve(path.dirname(binary), jsMatch[0]); + if (fs.existsSync(candidate)) { + const realInterp = opencodeShimInterpreter(candidate); + if (realInterp) { + args.unshift(candidate); + binary = realInterp; + } + } + } + } catch { + } + } + } + + const child = spawn(binary, args, { + cwd, + env: processEnv, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const url = await new Promise((resolve, reject) => { + let output = ''; + let done = false; + const finish = (handler, value) => { + if (done) return; + done = true; + clearTimeout(timer); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + child.off('error', onError); + handler(value); + }; + + const onStdout = (chunk) => { + output += chunk.toString(); + const lines = output.split('\n'); + for (const line of lines) { + if (!line.startsWith('opencode server listening')) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match) { + finish(reject, new Error(`Failed to parse server url from output: ${line}`)); + return; + } + finish(resolve, match[1]); + return; + } + }; + + const onStderr = (chunk) => { + output += chunk.toString(); + }; + + const onExit = (code) => { + finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); + }; + + const onError = (error) => { + finish(reject, error); + }; + + const timer = setTimeout(() => { + finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); + }, timeout); + + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.on('exit', onExit); + child.on('error', onError); + }); + + return { + url, + close() { + try { + child.kill('SIGTERM'); + } catch { + } + }, + }; + }; + + const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => { + if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { + return requestedPort; + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + const cleanup = () => { + server.removeAllListeners('error'); + server.removeAllListeners('listening'); + }; + + server.once('error', (error) => { + cleanup(); + reject(error); + }); + + server.once('listening', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close(() => { + cleanup(); + if (port > 0) { + resolve(port); + return; + } + reject(new Error('Failed to allocate OpenCode port')); + }); + }); + + server.listen(0, hostname); + }); + }; + + const isOpenCodeProcessHealthy = async () => { + if (!state.openCodeProcess || !state.openCodePort) { + return false; + } + + try { + const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, { + method: 'GET', + headers: getOpenCodeAuthHeaders(), + signal: AbortSignal.timeout(2000), + }); + return response.ok; + } catch { + return false; + } + }; + + const probeExternalOpenCode = async (port, origin) => { + if (!port || port <= 0) { + return false; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const base = origin ?? `http://127.0.0.1:${port}`; + const response = await fetch(`${base}/global/health`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (!response.ok) return false; + const body = await response.json().catch(() => null); + return body?.healthy === true; + } catch { + return false; + } + }; + + const waitForOpenCodePort = async (timeoutMs = 15000) => { + if (state.openCodePort !== null) { + return state.openCodePort; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (state.openCodePort !== null) { + return state.openCodePort; + } + } + + throw new Error('Timed out waiting for OpenCode port'); + }; + + const startOpenCode = async () => { + const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0; + const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME); + console.log( + desiredPort > 0 + ? `Starting OpenCode on requested port ${desiredPort}...` + : `Starting OpenCode on allocated port ${spawnPort}...` + ); + + await applyOpencodeBinaryFromSettings(); + ensureOpencodeCliEnv(); + const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true }); + + try { + const serverInstance = await createManagedOpenCodeServerProcess({ + hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME, + port: spawnPort, + timeout: 30000, + cwd: state.openCodeWorkingDirectory, + env: { + ...process.env, + OPENCODE_SERVER_PASSWORD: openCodePassword, + }, + }); + + if (!serverInstance || !serverInstance.url) { + throw new Error('OpenCode server started but URL is missing'); + } + + const url = new URL(serverInstance.url); + const port = parseInt(url.port, 10); + const prefix = normalizeApiPrefix(url.pathname); + + if (await waitForReady(serverInstance.url, 10000)) { + setOpenCodePort(port); + setDetectedOpenCodeApiPrefix(prefix); + + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + + return serverInstance; + } + + try { + serverInstance.close(); + } catch { + } + throw new Error('Server started but health check failed (timeout)'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + state.lastOpenCodeError = message; + state.openCodePort = null; + syncToHmrState(); + console.error(`Failed to start OpenCode: ${message}`); + throw error; + } + }; + + const restartOpenCode = async () => { + if (state.isShuttingDown) return; + if (state.currentRestartPromise) { + await state.currentRestartPromise; + return; + } + + state.currentRestartPromise = (async () => { + state.isRestartingOpenCode = true; + state.isOpenCodeReady = false; + state.openCodeNotReadySince = Date.now(); + console.log('Restarting OpenCode process...'); + + if (state.isExternalOpenCode) { + console.log('Re-probing external OpenCode server...'); + const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096; + const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin; + const healthy = await probeExternalOpenCode(probePort, probeOrigin); + if (healthy) { + console.log(`External OpenCode server on port ${probePort} is healthy`); + setOpenCodePort(probePort); + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else { + state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`; + console.error(state.lastOpenCodeError); + throw new Error(state.lastOpenCodeError); + } + + if (state.expressApp) { + setupProxy(state.expressApp); + ensureOpenCodeApiPrefix(); + } + return; + } + + const portToKill = state.openCodePort; + + if (state.openCodeProcess) { + console.log('Stopping existing OpenCode process...'); + try { + state.openCodeProcess.close(); + } catch (error) { + console.warn('Error closing OpenCode process:', error); + } + state.openCodeProcess = null; + syncToHmrState(); + } + + killProcessOnPort(portToKill); + await new Promise((resolve) => setTimeout(resolve, 250)); + + if (env.ENV_CONFIGURED_OPENCODE_PORT) { + console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`); + setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT); + } else { + state.openCodePort = null; + syncToHmrState(); + } + + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + if (state.openCodeApiDetectionTimer) { + clearTimeout(state.openCodeApiDetectionTimer); + state.openCodeApiDetectionTimer = null; + } + + state.lastOpenCodeError = null; + state.openCodeProcess = await startOpenCode(); + syncToHmrState(); + + if (state.expressApp) { + setupProxy(state.expressApp); + ensureOpenCodeApiPrefix(); + } + })(); + + try { + await state.currentRestartPromise; + } catch (error) { + console.error(`Failed to restart OpenCode: ${error.message}`); + state.lastOpenCodeError = error.message; + if (!env.ENV_CONFIGURED_OPENCODE_PORT) { + state.openCodePort = null; + syncToHmrState(); + } + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + throw error; + } finally { + state.currentRestartPromise = null; + state.isRestartingOpenCode = false; + } + }; + + const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + try { + const [configResult, agentResult] = await Promise.all([ + fetch(buildOpenCodeUrl('/config', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }).catch((error) => error), + fetch(buildOpenCodeUrl('/agent', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }).catch((error) => error), + ]); + + if (configResult instanceof Error) { + lastError = configResult; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + if (!configResult.ok) { + lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + await configResult.json().catch(() => null); + + if (agentResult instanceof Error) { + lastError = agentResult; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + if (!agentResult.ok) { + lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + await agentResult.json().catch(() => []); + + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + return; + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + if (lastError) { + state.lastOpenCodeError = lastError.message || String(lastError); + throw lastError; + } + + const timeoutError = new Error('Timed out waiting for OpenCode to become ready'); + state.lastOpenCodeError = timeoutError.message; + throw timeoutError; + }; + + const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(buildOpenCodeUrl('/agent'), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + + if (response.ok) { + const agents = await response.json(); + if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) { + return; + } + } + } catch { + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Agent "${agentName}" not available after OpenCode restart`); + }; + + const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => { + const { agentName } = options; + + console.log(`Refreshing OpenCode after ${reason}`); + clearResolvedOpenCodeBinary(); + await applyOpencodeBinaryFromSettings(); + + await restartOpenCode(); + + try { + await waitForOpenCodeReady(); + state.isOpenCodeReady = true; + state.openCodeNotReadySince = 0; + + if (agentName) { + await waitForAgentPresence(agentName); + } + + state.isOpenCodeReady = true; + state.openCodeNotReadySince = 0; + } catch (error) { + state.isOpenCodeReady = false; + state.openCodeNotReadySince = Date.now(); + console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); + throw error; + } + }; + + const bootstrapOpenCodeAtStartup = async () => { + try { + syncFromHmrState(); + if (await isOpenCodeProcessHealthy()) { + console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`); + } else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) { + const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; + console.log(`Using external OpenCode server at ${label} (skip-start mode)`); + state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) { + const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; + console.log(`Auto-detected existing OpenCode server at ${label}`); + state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { + console.log('Auto-detected existing OpenCode server on default port 4096'); + setOpenCodePort(4096); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else { + if (env.ENV_EFFECTIVE_PORT) { + console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`); + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + } else { + state.openCodePort = null; + syncToHmrState(); + } + + state.lastOpenCodeError = null; + state.openCodeProcess = await startOpenCode(); + syncToHmrState(); + } + await waitForOpenCodePort(); + try { + await waitForOpenCodeReady(); + } catch (error) { + console.error(`OpenCode readiness check failed: ${error.message}`); + } + } catch (error) { + console.error(`Failed to start OpenCode: ${error.message}`); + console.log('Continuing without OpenCode integration...'); + state.lastOpenCodeError = error.message; + } + }; + + const startHealthMonitoring = (healthCheckIntervalMs) => { + if (state.healthCheckInterval) { + clearInterval(state.healthCheckInterval); + } + + state.healthCheckInterval = setInterval(async () => { + if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return; + + try { + const healthy = await isOpenCodeProcessHealthy(); + if (!healthy) { + console.log('OpenCode process not running, restarting...'); + await restartOpenCode(); + } + } catch (error) { + console.error(`Health check error: ${error.message}`); + } + }, healthCheckIntervalMs); + }; + + return { + killProcessOnPort, + startOpenCode, + restartOpenCode, + waitForOpenCodeReady, + waitForAgentPresence, + refreshOpenCodeAfterConfigChange, + bootstrapOpenCodeAtStartup, + startHealthMonitoring, + }; +}; diff --git a/packages/web/server/lib/opencode/network-runtime.js b/packages/web/server/lib/opencode/network-runtime.js new file mode 100644 index 00000000..7ca7874e --- /dev/null +++ b/packages/web/server/lib/opencode/network-runtime.js @@ -0,0 +1,98 @@ +export const createOpenCodeNetworkRuntime = (deps) => { + const { + state, + getOpenCodeAuthHeaders, + } = deps; + + const normalizeApiPrefix = (prefix) => { + if (!prefix) { + return ''; + } + + if (prefix.includes('://')) { + try { + const parsed = new URL(prefix); + return normalizeApiPrefix(parsed.pathname); + } catch { + return ''; + } + } + + const trimmed = prefix.trim(); + if (!trimmed || trimmed === '/') { + return ''; + } + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; + }; + + const waitForReady = async (url, timeoutMs = 10000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (response.ok) { + const body = await response.json().catch(() => null); + if (body?.healthy === true) { + return true; + } + } + } catch { + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + }; + + const setDetectedOpenCodeApiPrefix = () => { + state.openCodeApiPrefix = ''; + state.openCodeApiPrefixDetected = true; + if (state.openCodeApiDetectionTimer) { + clearTimeout(state.openCodeApiDetectionTimer); + state.openCodeApiDetectionTimer = null; + } + }; + + const buildOpenCodeUrl = (path, prefixOverride) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); + const fullPath = `${prefix}${normalizedPath}`; + const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`; + return `${base}${fullPath}`; + }; + + const detectOpenCodeApiPrefix = () => { + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + return true; + }; + + const ensureOpenCodeApiPrefix = () => detectOpenCodeApiPrefix(); + + const scheduleOpenCodeApiDetection = () => { + return; + }; + + return { + waitForReady, + normalizeApiPrefix, + setDetectedOpenCodeApiPrefix, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + scheduleOpenCodeApiDetection, + }; +}; diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js new file mode 100644 index 00000000..faba7cb6 --- /dev/null +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -0,0 +1,284 @@ +export const registerOpenChamberRoutes = (app, dependencies) => { + const { + fs, + os, + path, + process, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + readSettingsFromDiskMigrated, + fetchFreeZenModels, + getCachedZenModels, + } = dependencies; + + let cachedModelsMetadata = null; + let cachedModelsMetadataTimestamp = 0; + + app.get('/api/openchamber/update-check', async (req, res) => { + try { + const { checkForUpdates } = await import('../package-manager.js'); + const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); + const parseReportUsage = (value) => { + if (typeof value !== 'string') return true; + const normalized = value.trim().toLowerCase(); + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return true; + }; + const inferDeviceClass = (ua) => { + const value = (ua || '').toLowerCase(); + if (!value) return 'unknown'; + if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; + if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; + return 'desktop'; + }; + const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; + + const updateInfo = await checkForUpdates({ + appType: parseString(req.query.appType), + deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), + platform: parseString(req.query.platform), + arch: parseString(req.query.arch), + instanceMode: parseString(req.query.instanceMode), + currentVersion: parseString(req.query.currentVersion), + reportUsage: parseReportUsage(parseString(req.query.reportUsage)), + }); + res.json(updateInfo); + } catch (error) { + console.error('Failed to check for updates:', error); + res.status(500).json({ + available: false, + error: error instanceof Error ? error.message : 'Failed to check for updates', + }); + } + }); + + app.post('/api/openchamber/update-install', async (_req, res) => { + try { + const { spawn: spawnChild } = await import('child_process'); + const { + checkForUpdates, + getUpdateCommand, + detectPackageManager, + } = await import('../package-manager.js'); + + const updateInfo = await checkForUpdates(); + if (!updateInfo.available) { + return res.status(400).json({ error: 'No update available' }); + } + + const pm = detectPackageManager(); + const updateCmd = getUpdateCommand(pm); + const isContainer = + fs.existsSync('/.dockerenv') || + Boolean(process.env.CONTAINER) || + process.env.container === 'docker'; + + if (isContainer) { + res.json({ + success: true, + message: 'Update starting, server will stay online', + version: updateInfo.version, + packageManager: pm, + autoRestart: false, + }); + + setTimeout(() => { + console.log(`\nInstalling update using ${pm} (container mode)...`); + console.log(`Running: ${updateCmd}`); + + const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + const child = spawnChild(shell, [shellFlag, updateCmd], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + child.unref(); + }, 500); + + return; + } + + const currentPort = server.address()?.port || 3000; + const tmpDir = os.tmpdir(); + const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`); + let storedOptions = { port: currentPort, daemon: true }; + try { + const content = await fs.promises.readFile(instanceFilePath, 'utf8'); + storedOptions = JSON.parse(content); + } catch { + } + + const isWindows = process.platform === 'win32'; + const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; + const quoteCmd = (value) => { + const stringValue = String(value); + return `"${stringValue.replace(/"/g, '""')}"`; + }; + + const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js'); + const restartParts = [ + isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), + isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), + 'serve', + '--port', + String(storedOptions.port), + '--daemon', + ]; + let restartCmdPrimary = restartParts.join(' '); + let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; + if (storedOptions.uiPassword) { + if (isWindows) { + const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); + restartCmdPrimary += ` --ui-password "${escapedPw}"`; + restartCmdFallback += ` --ui-password "${escapedPw}"`; + } else { + const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); + restartCmdPrimary += ` --ui-password '${escapedPw}'`; + restartCmdFallback += ` --ui-password '${escapedPw}'`; + } + } + const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; + + res.json({ + success: true, + message: 'Update starting, server will restart shortly', + version: updateInfo.version, + packageManager: pm, + autoRestart: true, + }); + + setTimeout(() => { + console.log(`\nInstalling update using ${pm}...`); + console.log(`Running: ${updateCmd}`); + + const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; + const shellFlag = isWindows ? '/c' : '-c'; + const script = isWindows + ? ` + timeout /t 2 /nobreak >nul + ${updateCmd} + if %ERRORLEVEL% EQU 0 ( + echo Update successful, restarting OpenChamber... + ${restartCmd} + ) else ( + echo Update failed + exit /b 1 + ) + ` + : ` + sleep 2 + ${updateCmd} + if [ $? -eq 0 ]; then + echo "Update successful, restarting OpenChamber..." + ${restartCmd} + else + echo "Update failed" + exit 1 + fi + `; + + const updateLogPath = path.join(openchamberDataDir, 'update-install.log'); + let logFd = null; + try { + fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); + logFd = fs.openSync(updateLogPath, 'a'); + } catch (logError) { + console.warn('Failed to open update log file, continuing without log capture:', logError); + } + + const child = spawnChild(shell, [shellFlag, script], { + detached: true, + stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', + env: process.env, + }); + child.unref(); + + if (logFd !== null) { + try { + fs.closeSync(logFd); + } catch { + } + } + + console.log('Update process spawned, shutting down server...'); + + setTimeout(() => { + process.exit(0); + }, 500); + }, 500); + } catch (error) { + console.error('Failed to install update:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to install update', + }); + } + }); + + app.get('/api/openchamber/models-metadata', async (_req, res) => { + const now = Date.now(); + + if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) { + res.setHeader('Cache-Control', 'public, max-age=60'); + return res.json(cachedModelsMetadata); + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; + + try { + const response = await fetch(modelsDevApiUrl, { + signal: controller?.signal, + headers: { + Accept: 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`models.dev responded with status ${response.status}`); + } + + const metadata = await response.json(); + cachedModelsMetadata = metadata; + cachedModelsMetadataTimestamp = Date.now(); + + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json(metadata); + } catch (error) { + console.warn('Failed to fetch models.dev metadata via server:', error); + + if (cachedModelsMetadata) { + res.setHeader('Cache-Control', 'public, max-age=60'); + res.json(cachedModelsMetadata); + } else { + const statusCode = error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + }); + + app.get('/api/zen/models', async (_req, res) => { + try { + const models = await fetchFreeZenModels(); + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json({ models }); + } catch (error) { + console.warn('Failed to fetch zen models:', error); + const cachedZenModels = getCachedZenModels(); + if (cachedZenModels) { + res.setHeader('Cache-Control', 'public, max-age=60'); + res.json(cachedZenModels); + } else { + const statusCode = error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); + } + } + }); +}; diff --git a/packages/web/server/lib/opencode/opencode-resolution-runtime.js b/packages/web/server/lib/opencode/opencode-resolution-runtime.js new file mode 100644 index 00000000..5495f8e9 --- /dev/null +++ b/packages/web/server/lib/opencode/opencode-resolution-runtime.js @@ -0,0 +1,67 @@ +export const createOpenCodeResolutionRuntime = (dependencies) => { + const { + path, + resolveOpencodeCliPath, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + opencodeShimInterpreter, + getResolvedState, + setResolvedOpencodeBinarySource, + } = dependencies; + + const getOpenCodeResolutionSnapshot = async (settings) => { + const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null; + + const { resolvedOpencodeBinarySource: previousSource } = getResolvedState(); + const detectedNow = resolveOpencodeCliPath(); + const { resolvedOpencodeBinarySource: rawDetectedSourceNow } = getResolvedState(); + setResolvedOpencodeBinarySource(previousSource); + + await applyOpencodeBinaryFromSettings(); + ensureOpencodeCliEnv(); + + const { + resolvedOpencodeBinary, + resolvedOpencodeBinarySource, + useWslForOpencode, + resolvedWslBinary, + resolvedWslOpencodePath, + resolvedWslDistro, + resolvedNodeBinary, + resolvedBunBinary, + } = getResolvedState(); + + const resolved = resolvedOpencodeBinary || null; + const source = resolvedOpencodeBinarySource || null; + const detectedSourceNow = + detectedNow && + resolved && + detectedNow === resolved && + rawDetectedSourceNow === 'env' && + source && + source !== 'env' + ? source + : rawDetectedSourceNow; + const shim = resolved ? opencodeShimInterpreter(resolved) : null; + + return { + configured, + resolved, + resolvedDir: resolved ? path.dirname(resolved) : null, + source, + detectedNow, + detectedSourceNow, + shim, + viaWsl: useWslForOpencode, + wslBinary: resolvedWslBinary || null, + wslPath: resolvedWslOpencodePath || null, + wslDistro: resolvedWslDistro || null, + node: resolvedNodeBinary || null, + bun: resolvedBunBinary || null, + }; + }; + + return { + getOpenCodeResolutionSnapshot, + }; +}; diff --git a/packages/web/server/lib/opencode/project-directory-runtime.js b/packages/web/server/lib/opencode/project-directory-runtime.js new file mode 100644 index 00000000..ee7d99a7 --- /dev/null +++ b/packages/web/server/lib/opencode/project-directory-runtime.js @@ -0,0 +1,109 @@ +export const createProjectDirectoryRuntime = (dependencies) => { + const { + fsPromises, + path, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + getReadSettingsFromDiskMigrated, + sanitizeProjects, + } = dependencies; + + const resolveDirectoryCandidate = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeDirectoryPath(trimmed); + return path.resolve(normalized); + }; + + const validateDirectoryPath = async (candidate) => { + const resolved = resolveDirectoryCandidate(candidate); + if (!resolved) { + return { ok: false, error: 'Directory parameter is required' }; + } + try { + const stats = await fsPromises.stat(resolved); + if (!stats.isDirectory()) { + return { ok: false, error: 'Specified path is not a directory' }; + } + return { ok: true, directory: resolved }; + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return { ok: false, error: 'Directory not found' }; + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return { ok: false, error: 'Access to directory denied' }; + } + return { ok: false, error: 'Failed to validate directory' }; + } + }; + + const resolveProjectDirectory = async (req) => { + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requested = headerDirectory || queryDirectory || null; + + if (requested) { + const validated = await validateDirectoryPath(requested); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + return { directory: validated.directory, error: null }; + } + + const readSettings = typeof getReadSettingsFromDiskMigrated === 'function' + ? getReadSettingsFromDiskMigrated() + : readSettingsFromDiskMigrated; + const settings = await readSettings(); + const projects = sanitizeProjects(settings.projects) || []; + if (projects.length === 0) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const active = projects.find((project) => project.id === activeId) || projects[0]; + if (!active || !active.path) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const validated = await validateDirectoryPath(active.path); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + + return { directory: validated.directory, error: null }; + }; + + const resolveOptionalProjectDirectory = async (req) => { + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requested = headerDirectory || queryDirectory || null; + + if (!requested) { + return { directory: null, error: null }; + } + + const validated = await validateDirectoryPath(requested); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + + return { directory: validated.directory, error: null }; + }; + + return { + resolveDirectoryCandidate, + validateDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + }; +}; diff --git a/packages/web/server/lib/opencode/project-icon-routes.js b/packages/web/server/lib/opencode/project-icon-routes.js new file mode 100644 index 00000000..634ad972 --- /dev/null +++ b/packages/web/server/lib/opencode/project-icon-routes.js @@ -0,0 +1,397 @@ +export const registerProjectIconRoutes = (app, dependencies) => { + const { + fsPromises, + path, + crypto, + openchamberDataDir, + sanitizeProjects, + readSettingsFromDiskMigrated, + persistSettings, + createFsSearchRuntime, + spawn, + resolveGitBinaryForSpawn, + } = dependencies; + + const projectIconsDirPath = path.join(openchamberDataDir, 'project-icons'); + const projectIconMimeToExtension = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + 'image/x-icon': 'ico', + }; + const projectIconExtensionToMime = Object.fromEntries( + Object.entries(projectIconMimeToExtension).map(([mime, ext]) => [ext, mime]) + ); + const projectIconSupportedMimes = new Set(Object.keys(projectIconMimeToExtension)); + const projectIconMaxBytes = 5 * 1024 * 1024; + const projectIconThemeColors = { + light: '#111111', + dark: '#f5f5f5', + }; + const projectIconHexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/; + + const normalizeProjectIconMime = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'image/jpg') { + return 'image/jpeg'; + } + if (projectIconSupportedMimes.has(normalized)) { + return normalized; + } + return null; + }; + + const projectIconBaseName = (projectId) => { + const hash = crypto.createHash('sha1').update(projectId).digest('hex'); + return `project-${hash}`; + }; + + const projectIconPathForMime = (projectId, mime) => { + const normalizedMime = normalizeProjectIconMime(mime); + if (!normalizedMime) { + return null; + } + const ext = projectIconMimeToExtension[normalizedMime]; + return path.join(projectIconsDirPath, `${projectIconBaseName(projectId)}.${ext}`); + }; + + const projectIconPathCandidates = (projectId) => { + const base = projectIconBaseName(projectId); + return Object.values(projectIconMimeToExtension).map((ext) => path.join(projectIconsDirPath, `${base}.${ext}`)); + }; + + const removeProjectIconFiles = async (projectId, keepPath) => { + const candidates = projectIconPathCandidates(projectId); + await Promise.all(candidates.map(async (candidatePath) => { + if (keepPath && candidatePath === keepPath) { + return; + } + try { + await fsPromises.unlink(candidatePath); + } catch (error) { + if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { + throw error; + } + } + })); + }; + + const parseProjectIconDataUrl = (value) => { + if (typeof value !== 'string') { + return { ok: false, error: 'dataUrl is required' }; + } + + const trimmed = value.trim(); + const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i); + if (!match) { + return { ok: false, error: 'Invalid dataUrl format' }; + } + + const mime = normalizeProjectIconMime(match[1]); + if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) { + return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' }; + } + + try { + const base64 = match[2].replace(/\s+/g, ''); + const bytes = Buffer.from(base64, 'base64'); + if (bytes.length === 0) { + return { ok: false, error: 'Icon content is empty' }; + } + if (bytes.length > projectIconMaxBytes) { + return { ok: false, error: 'Icon exceeds size limit (5 MB)' }; + } + return { ok: true, mime, bytes }; + } catch { + return { ok: false, error: 'Failed to decode icon data' }; + } + }; + + const normalizeProjectIconThemeVariant = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'light' || normalized === 'dark') { + return normalized; + } + return null; + }; + + const normalizeProjectIconColor = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + if (!projectIconHexColorPattern.test(normalized)) { + return null; + } + return normalized; + }; + + const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => { + if (typeof svgMarkup !== 'string') { + return svgMarkup; + } + + const color = iconColor || projectIconThemeColors[themeVariant]; + if (!color) { + return svgMarkup; + } + + const svgTagIndex = svgMarkup.search(/', svgTagIndex); + if (svgOpenTagEndIndex === -1) { + return svgMarkup; + } + + const overrideStyle = ``; + return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`; + }; + + const findProjectById = (settings, projectId) => { + const projects = sanitizeProjects(settings?.projects) || []; + const index = projects.findIndex((project) => project.id === projectId); + if (index === -1) { + return { projects, index: -1, project: null }; + } + return { projects, index, project: projects[index] }; + }; + + const fsSearchRuntime = createFsSearchRuntime({ + fsPromises, + path, + spawn, + resolveGitBinaryForSpawn, + }); + + app.get('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const metadataMime = normalizeProjectIconMime(project.iconImage?.mime); + const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null; + const candidates = preferredPath + ? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)] + : projectIconPathCandidates(projectId); + + const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme; + const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery); + const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor; + const requestedIconColor = normalizeProjectIconColor(iconColorQuery); + + for (const iconPath of candidates) { + try { + const data = await fsPromises.readFile(iconPath); + const ext = path.extname(iconPath).slice(1).toLowerCase(); + const resolvedMime = metadataMime || projectIconExtensionToMime[ext] || 'application/octet-stream'; + const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime; + + if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + + if (resolvedMime === 'image/svg+xml' && requestedIconColor) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(data); + } catch (error) { + if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { + console.warn('Failed to read project icon:', error); + return res.status(500).json({ error: 'Failed to read project icon' }); + } + } + } + + return res.status(404).json({ error: 'Project icon not found' }); + } catch (error) { + console.warn('Failed to load project icon:', error); + return res.status(500).json({ error: 'Failed to load project icon' }); + } + }); + + app.put('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + const parsed = parseProjectIconDataUrl(req.body?.dataUrl); + if (!parsed.ok) { + return res.status(400).json({ error: parsed.error }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const iconPath = projectIconPathForMime(projectId, parsed.mime); + if (!iconPath) { + return res.status(400).json({ error: 'Unsupported icon format' }); + } + + await fsPromises.mkdir(projectIconsDirPath, { recursive: true }); + await fsPromises.writeFile(iconPath, parsed.bytes); + await removeProjectIconFiles(projectId, iconPath); + + const updatedAt = Date.now(); + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ project: updatedProject, settings: updatedSettings }); + } catch (error) { + console.warn('Failed to upload project icon:', error); + return res.status(500).json({ error: 'Failed to upload project icon' }); + } + }); + + app.delete('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + await removeProjectIconFiles(projectId); + + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: null } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ project: updatedProject, settings: updatedSettings }); + } catch (error) { + console.warn('Failed to remove project icon:', error); + return res.status(500).json({ error: 'Failed to remove project icon' }); + } + }); + + app.post('/api/projects/:projectId/icon/discover', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const force = req.body?.force === true; + if (project.iconImage?.source === 'custom' && !force) { + return res.json({ + project, + skipped: true, + reason: 'custom-icon-present', + }); + } + + const faviconCandidates = await fsSearchRuntime.searchFilesystemFiles(project.path, { + limit: 200, + query: 'favicon', + includeHidden: true, + respectGitignore: false, + }); + + const filtered = faviconCandidates + .filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path)) + .sort((a, b) => a.path.length - b.path.length); + + const selected = filtered[0]; + if (!selected) { + return res.status(404).json({ error: 'No favicon found in project' }); + } + + const ext = path.extname(selected.path).slice(1).toLowerCase(); + const mime = projectIconExtensionToMime[ext] || null; + if (!mime) { + return res.status(415).json({ error: 'Unsupported favicon format' }); + } + + const bytes = await fsPromises.readFile(selected.path); + if (bytes.length === 0) { + return res.status(400).json({ error: 'Discovered icon is empty' }); + } + if (bytes.length > projectIconMaxBytes) { + return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' }); + } + + const iconPath = projectIconPathForMime(projectId, mime); + if (!iconPath) { + return res.status(415).json({ error: 'Unsupported favicon format' }); + } + + await fsPromises.mkdir(projectIconsDirPath, { recursive: true }); + await fsPromises.writeFile(iconPath, bytes); + await removeProjectIconFiles(projectId, iconPath); + + const updatedAt = Date.now(); + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ + project: updatedProject, + settings: updatedSettings, + discoveredPath: selected.path, + }); + } catch (error) { + console.warn('Failed to discover project icon:', error); + return res.status(500).json({ error: 'Failed to discover project icon' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js new file mode 100644 index 00000000..ffeb2ae4 --- /dev/null +++ b/packages/web/server/lib/opencode/proxy.js @@ -0,0 +1,169 @@ +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; + +export const registerOpenCodeProxy = (app, deps) => { + const { + fs, + os, + path, + OPEN_CODE_READY_GRACE_MS, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + } = deps; + + if (app.get('opencodeProxyConfigured')) { + return; + } + + const runtime = getRuntime(); + if (runtime.openCodePort) { + console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`); + } else { + console.log('Setting up OpenCode API gate (OpenCode not started yet)'); + } + app.set('opencodeProxyConfigured', true); + + // Ensure API prefix is detected before proxying + app.use('/api', (_req, _res, next) => { + ensureOpenCodeApiPrefix(); + next(); + }); + + // Readiness gate — return 503 while OpenCode is starting/restarting + app.use('/api', (req, res, next) => { + if ( + req.path.startsWith('/themes/custom') || + req.path.startsWith('/push') || + req.path.startsWith('/config/agents') || + req.path.startsWith('/config/opencode-resolution') || + req.path.startsWith('/config/settings') || + req.path.startsWith('/config/skills') || + req.path === '/config/reload' || + req.path === '/health' + ) { + return next(); + } + + const runtimeState = getRuntime(); + const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince; + const stillWaiting = + (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || + runtimeState.isRestartingOpenCode || + !runtimeState.openCodePort; + + if (stillWaiting) { + return res.status(503).json({ + error: 'OpenCode is restarting', + restarting: true, + }); + } + + next(); + }); + + // Windows: session merge for cross-directory session listing + if (process.platform === 'win32') { + app.get('/api/session', async (req, res, next) => { + const rawUrl = req.originalUrl || req.url || ''; + if (rawUrl.includes('directory=')) return next(); + + try { + const authHeaders = getOpenCodeAuthHeaders(); + const fetchOpts = { + method: 'GET', + headers: { Accept: 'application/json', ...authHeaders }, + signal: AbortSignal.timeout(10000), + }; + const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); + const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : []; + const globalSessions = Array.isArray(globalPayload) ? globalPayload : []; + + const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + let projectDirs = []; + try { + const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(settingsRaw); + projectDirs = (settings.projects || []) + .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) + .filter(Boolean); + } catch { + } + + const seen = new Set( + globalSessions + .map((session) => (session && typeof session.id === 'string' ? session.id : null)) + .filter((id) => typeof id === 'string') + ); + const extraSessions = []; + for (const dir of projectDirs) { + const candidates = Array.from(new Set([ + dir, + dir.replace(/\\/g, '/'), + dir.replace(/\//g, '\\'), + ])); + for (const candidateDir of candidates) { + const encoded = encodeURIComponent(candidateDir); + try { + const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); + if (dirRes.ok) { + const dirPayload = await dirRes.json().catch(() => []); + const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; + for (const session of dirSessions) { + const id = session && typeof session.id === 'string' ? session.id : null; + if (id && !seen.has(id)) { + seen.add(id); + extraSessions.push(session); + } + } + } + } catch { + } + } + } + + const merged = [...globalSessions, ...extraSessions]; + merged.sort((a, b) => { + const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; + const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; + return bTime - aTime; + }); + console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); + return res.json(merged); + } catch (error) { + console.log(`[SessionMerge] Error: ${error.message}, falling through`); + next(); + } + }); + } + + // http-proxy-middleware handles SSE, large bodies, timeouts correctly + const apiProxy = createProxyMiddleware({ + target: `http://127.0.0.1:${runtime.openCodePort || 3902}`, + changeOrigin: true, + pathRewrite: { '^/api': '' }, + // Dynamic target — port can change after restart + router: () => { + const rt = getRuntime(); + return `http://127.0.0.1:${rt.openCodePort || 3902}`; + }, + on: { + proxyReq: (proxyReq) => { + // Inject OpenCode auth headers + const authHeaders = getOpenCodeAuthHeaders(); + if (authHeaders.Authorization) { + proxyReq.setHeader('Authorization', authHeaders.Authorization); + } + }, + error: (err, _req, res) => { + console.error('[proxy] OpenCode proxy error:', err.message); + if (res && !res.headersSent && typeof res.status === 'function') { + res.status(503).json({ error: 'OpenCode service unavailable' }); + } + }, + }, + }); + + app.use('/api', apiProxy); +}; diff --git a/packages/web/server/lib/opencode/pwa-manifest-routes.js b/packages/web/server/lib/opencode/pwa-manifest-routes.js new file mode 100644 index 00000000..fd99e49b --- /dev/null +++ b/packages/web/server/lib/opencode/pwa-manifest-routes.js @@ -0,0 +1,238 @@ +const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant'; + +export const registerPwaManifestRoute = (app, dependencies) => { + const { + process, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + } = dependencies; + + const recentPwaSessionsCache = new Map(); + + const getRecentPwaSessionShortcuts = async (req) => { + const now = Date.now(); + + const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null })); + const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string' + ? resolvedDirectoryResult.directory + : null; + + const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global'; + const cached = recentPwaSessionsCache.get(cacheKey); + if (cached && now - cached.at < 5000) { + return cached.data; + } + + const normalizeShortcutTitle = (value, fallback) => { + const normalized = normalizePwaAppName(value, fallback); + return normalized.length > 48 ? normalized.slice(0, 48) : normalized; + }; + + const toFiniteNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; + }; + + const normalizeDirectory = (value) => { + if (typeof value !== 'string') { + return ''; + } + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + const normalized = trimmed.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; + }; + + const sessionUpdatedAt = (session) => { + const time = session && typeof session.time === 'object' ? session.time : null; + return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0; + }; + + const filterSessionsByDirectory = (sessions, directory) => { + const normalizedDirectory = normalizeDirectory(directory); + if (!normalizedDirectory) { + return sessions; + } + + const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`; + return sessions.filter((session) => { + const sessionDirectory = normalizeDirectory(session?.directory); + if (!sessionDirectory) { + return false; + } + return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix)); + }); + }; + + const listSessions = async (directory) => { + const query = (() => { + if (typeof directory !== 'string' || directory.length === 0) { + return ''; + } + const preparedDirectory = process.platform === 'win32' + ? directory.replace(/\//g, '\\\\') + : directory; + return `?directory=${encodeURIComponent(preparedDirectory)}`; + })(); + + const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(2500), + }); + + if (!response.ok) { + return []; + } + + const payload = await response.json().catch(() => null); + return Array.isArray(payload) ? payload : []; + }; + + try { + let payload = []; + + if (preferredDirectory) { + const scopedPayload = await listSessions(preferredDirectory); + const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory); + + if (filteredScopedPayload.length > 0) { + payload = filteredScopedPayload; + } else { + const globalPayload = await listSessions(null); + const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory); + payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload; + } + } else { + payload = await listSessions(null); + } + + const seen = new Set(); + const rows = []; + + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + + const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : ''; + if (!id || seen.has(id)) { + continue; + } + + seen.add(id); + const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`); + const updatedAt = sessionUpdatedAt(item); + + rows.push({ id, title, updatedAt }); + } + + rows.sort((a, b) => b.updatedAt - a.updatedAt); + + const shortcuts = rows.slice(0, 3).map((session) => ({ + name: session.title, + short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title, + description: 'Open recent session', + url: `/?session=${encodeURIComponent(session.id)}`, + icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + })); + + recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts }); + return shortcuts; + } catch { + recentPwaSessionsCache.set(cacheKey, { at: now, data: [] }); + return []; + } + }; + + app.get('/manifest.webmanifest', async (req, res) => { + const hasQueryOverride = + typeof req.query?.pwa_name === 'string' + || typeof req.query?.app_name === 'string' + || typeof req.query?.appName === 'string'; + + let queryValueRaw = ''; + if (typeof req.query?.pwa_name === 'string') { + queryValueRaw = req.query.pwa_name; + } else if (typeof req.query?.app_name === 'string') { + queryValueRaw = req.query.app_name; + } else if (typeof req.query?.appName === 'string') { + queryValueRaw = req.query.appName; + } + + const queryOverrideName = normalizePwaAppName(queryValueRaw, ''); + + let storedName = ''; + try { + const settings = await readSettingsFromDiskMigrated(); + storedName = normalizePwaAppName(settings?.pwaAppName, ''); + } catch { + storedName = ''; + } + + const appName = hasQueryOverride + ? (queryOverrideName || DEFAULT_PWA_APP_NAME) + : (storedName || DEFAULT_PWA_APP_NAME); + + const shortName = appName.length > 30 ? appName.slice(0, 30) : appName; + const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req); + + const manifest = { + name: appName, + short_name: shortName, + description: 'Web interface companion for OpenCode AI coding agent', + id: '/', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: '#151313', + theme_color: '#edb449', + orientation: 'any', + icons: [ + { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, + { src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + { src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' }, + { src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' }, + { src: '/favicon-32.png', sizes: '32x32', type: 'image/png' }, + { src: '/favicon-16.png', sizes: '16x16', type: 'image/png' }, + ], + shortcuts: [ + { + name: 'Appearance Settings', + short_name: 'Settings', + description: 'Open appearance settings', + url: '/?settings=appearance', + icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + }, + ...recentSessionShortcuts, + ], + categories: ['developer', 'tools', 'productivity'], + lang: 'en', + }; + + res.setHeader('Cache-Control', 'no-store, must-revalidate'); + res.type('application/manifest+json'); + res.send(JSON.stringify(manifest)); + }); +}; diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js new file mode 100644 index 00000000..3dde098e --- /dev/null +++ b/packages/web/server/lib/opencode/routes.js @@ -0,0 +1,206 @@ +export const registerOpenCodeRoutes = (app, dependencies) => { + const { + crypto, + clientReloadDelayMs, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + validateDirectoryPath, + resolveProjectDirectory, + getProviderSources, + removeProviderConfig, + refreshOpenCodeAfterConfigChange, + } = dependencies; + + let authLibrary = null; + const getAuthLibrary = async () => { + if (!authLibrary) { + authLibrary = await import('./auth.js'); + } + return authLibrary; + }; + + app.get('/api/config/settings', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + res.json(formatSettingsResponse(settings)); + } catch (error) { + console.error('Failed to read settings:', error); + res.status(500).json({ error: 'Failed to read settings' }); + } + }); + + app.get('/api/config/opencode-resolution', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + const resolution = await getOpenCodeResolutionSnapshot(settings); + res.json(resolution); + } catch (error) { + console.error('Failed to resolve OpenCode binary:', error); + res.status(500).json({ error: 'Failed to resolve OpenCode binary' }); + } + }); + + app.put('/api/config/settings', async (req, res) => { + console.log('[API:PUT /api/config/settings] Received request'); + try { + const updated = await persistSettings(req.body ?? {}); + console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`); + res.json(updated); + } catch (error) { + console.error('[API:PUT /api/config/settings] Failed to save settings:', error); + console.error('[API:PUT /api/config/settings] Error stack:', error.stack); + res.status(500).json({ error: 'Failed to save settings' }); + } + }); + + app.get('/api/provider/:providerId/source', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requestedDirectory = headerDirectory || queryDirectory || null; + + let directory = null; + const resolved = await resolveProjectDirectory(req); + if (resolved.directory) { + directory = resolved.directory; + } else if (requestedDirectory) { + return res.status(400).json({ error: resolved.error }); + } + + const sources = getProviderSources(providerId, directory); + const { getProviderAuth } = await getAuthLibrary(); + const auth = getProviderAuth(providerId); + sources.sources.auth.exists = Boolean(auth); + + return res.json({ + providerId, + sources: sources.sources, + }); + } catch (error) { + console.error('Failed to get provider sources:', error); + return res.status(500).json({ error: error.message || 'Failed to get provider sources' }); + } + }); + + app.delete('/api/provider/:providerId/auth', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + + const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth'; + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requestedDirectory = headerDirectory || queryDirectory || null; + let directory = null; + + if (scope === 'project' || requestedDirectory) { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ error: resolved.error }); + } + directory = resolved.directory; + } else { + const resolved = await resolveProjectDirectory(req); + if (resolved.directory) { + directory = resolved.directory; + } + } + + let removed = false; + if (scope === 'auth') { + const { removeProviderAuth } = await getAuthLibrary(); + removed = removeProviderAuth(providerId); + } else if (scope === 'user' || scope === 'project' || scope === 'custom') { + removed = removeProviderConfig(providerId, directory, scope); + } else if (scope === 'all') { + const { removeProviderAuth } = await getAuthLibrary(); + const authRemoved = removeProviderAuth(providerId); + const userRemoved = removeProviderConfig(providerId, directory, 'user'); + const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false; + const customRemoved = removeProviderConfig(providerId, directory, 'custom'); + removed = authRemoved || userRemoved || projectRemoved || customRemoved; + } else { + return res.status(400).json({ error: 'Invalid scope' }); + } + + if (removed) { + await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`); + } + + return res.json({ + success: true, + removed, + requiresReload: removed, + message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', + reloadDelayMs: removed ? clientReloadDelayMs : undefined, + }); + } catch (error) { + console.error('Failed to disconnect provider:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect provider' }); + } + }); + + app.post('/api/opencode/directory', async (req, res) => { + try { + const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : ''; + if (!requestedPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + const validated = await validateDirectoryPath(requestedPath); + if (!validated.ok) { + return res.status(400).json({ error: validated.error }); + } + + const resolvedPath = validated.directory; + const currentSettings = await readSettingsFromDisk(); + const existingProjects = sanitizeProjects(currentSettings.projects) || []; + const existing = existingProjects.find((project) => project.path === resolvedPath) || null; + + const nextProjects = existing + ? existingProjects + : [ + ...existingProjects, + { + id: crypto.randomUUID(), + path: resolvedPath, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + }, + ]; + + const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id; + + const updated = await persistSettings({ + projects: nextProjects, + activeProjectId, + lastDirectory: resolvedPath, + }); + + return res.json({ + success: true, + restarted: false, + path: resolvedPath, + settings: updated, + }); + } catch (error) { + console.error('Failed to update OpenCode working directory:', error); + return res.status(500).json({ error: error.message || 'Failed to update working directory' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js new file mode 100644 index 00000000..ddcc4700 --- /dev/null +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -0,0 +1,138 @@ +export const createServerStartupRuntime = (dependencies) => { + const { + process, + crypto, + server, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + } = dependencies; + + const resolveBindHost = (host) => + host + || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 + ? process.env.OPENCHAMBER_HOST.trim() + : '127.0.0.1'); + + const startListeningAndMaybeTunnel = async ({ + port, + bindHost, + startupTunnelRequest, + onTunnelReady, + }) => { + let activePort = port; + + await new Promise((resolve, reject) => { + const onError = (error) => { + server.off('error', onError); + reject(error); + }; + server.once('error', onError); + const onListening = async () => { + server.off('error', onError); + const addressInfo = server.address(); + activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; + + try { + process.send?.({ type: 'openchamber:ready', port: activePort }); + } catch { + // ignore + } + + const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]') + ? 'localhost' + : (bindHost.includes(':') ? `[${bindHost}]` : bindHost); + console.log(`OpenChamber server listening on ${bindHost}:${activePort}`); + console.log(`Health check: http://${displayHost}:${activePort}/health`); + console.log(`Web interface: http://${displayHost}:${activePort}`); + + if (startupTunnelRequest) { + const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK + ? 'Quick Tunnel' + : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL + ? 'Managed Local Tunnel' + : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel')); + console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`); + try { + const { publicUrl, mode } = await startTunnelWithNormalizedRequest({ + provider: startupTunnelRequest.provider, + mode: startupTunnelRequest.mode, + intent: startupTunnelRequest.intent, + hostname: startupTunnelRequest.hostname, + token: startupTunnelRequest.token, + configPath: startupTunnelRequest.configPath, + selectedPresetId: '', + selectedPresetName: '', + }); + if (publicUrl) { + tunnelAuthController.setActiveTunnel({ + tunnelId: crypto.randomUUID(), + publicUrl, + mode, + }); + const settings = await readSettingsFromDiskMigrated(); + const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); + const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); + const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; + if (onTunnelReady) { + onTunnelReady(publicUrl, connectUrl); + } else { + console.log(`\n🌐 Tunnel URL: ${connectUrl}`); + console.log('🔑 One-time connect link (expires after first use)\n'); + } + } else if (onTunnelReady) { + onTunnelReady(publicUrl, null); + } + } catch (error) { + console.error(`Failed to start tunnel: ${error.message}`); + console.log('Continuing without tunnel...'); + } + } + + resolve(); + }; + + server.listen(port, bindHost, onListening); + }); + + return { activePort }; + }; + + const attachProcessHandlers = ({ attachSignals }) => { + if (attachSignals && !getSignalsAttached()) { + const handleSignal = async () => { + await gracefulShutdown(); + }; + process.on('SIGTERM', handleSignal); + process.on('SIGINT', handleSignal); + process.on('SIGQUIT', handleSignal); + setSignalsAttached(true); + syncToHmrState(); + } + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + gracefulShutdown(); + }); + }; + + return { + resolveBindHost, + startListeningAndMaybeTunnel, + attachProcessHandlers, + }; +}; diff --git a/packages/web/server/lib/opencode/server-utils-runtime.js b/packages/web/server/lib/opencode/server-utils-runtime.js new file mode 100644 index 00000000..940d0c92 --- /dev/null +++ b/packages/web/server/lib/opencode/server-utils-runtime.js @@ -0,0 +1,168 @@ +import { registerOpenCodeProxy } from './proxy.js'; + +export const createServerUtilsRuntime = (dependencies) => { + const { + fs, + os, + path, + process, + openCodeReadyGraceMs, + longRequestTimeoutMs, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients, + getOpenCodePort, + setOpenCodePortState, + syncToHmrState, + markOpenCodeNotReady, + setOpenCodeNotReadySince, + clearLastOpenCodeError, + getLoginShellPath, + } = dependencies; + + const setOpenCodePort = (port) => { + if (!Number.isFinite(port) || port <= 0) { + return; + } + + const numericPort = Math.trunc(port); + const currentPort = getOpenCodePort(); + const portChanged = currentPort !== numericPort; + + if (portChanged || currentPort === null) { + setOpenCodePortState(numericPort); + syncToHmrState(); + console.log(`Detected OpenCode port: ${numericPort}`); + + if (portChanged) { + markOpenCodeNotReady(); + } + setOpenCodeNotReadySince(Date.now()); + } + + clearLastOpenCodeError(); + }; + + const waitForOpenCodePort = async (timeoutMs = 15000) => { + if (getOpenCodePort() !== null) { + return getOpenCodePort(); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (getOpenCodePort() !== null) { + return getOpenCodePort(); + } + } + + throw new Error('Timed out waiting for OpenCode port'); + }; + + const buildAugmentedPath = () => { + const augmented = new Set(); + + const loginShellPath = getLoginShellPath(); + if (loginShellPath) { + for (const segment of loginShellPath.split(path.delimiter)) { + if (segment) { + augmented.add(segment); + } + } + } + + const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + for (const segment of current) { + augmented.add(segment); + } + + return Array.from(augmented).join(path.delimiter); + }; + + const parseSseDataPayload = (block) => { + if (!block || typeof block !== 'string') { + return null; + } + const dataLines = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).replace(/^\s/, '')); + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const parsed = JSON.parse(payloadText); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.payload === 'object' && + parsed.payload !== null + ) { + return parsed.payload; + } + return parsed; + } catch { + return null; + } + }; + + const fetchArraySnapshot = async (route, invalidMessage) => { + if (!getOpenCodePort()) { + throw new Error('OpenCode port is not available'); + } + + const response = await fetch(buildOpenCodeUrl(route), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch ${invalidMessage} (status ${response.status})`); + } + + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) { + throw new Error(`Invalid ${invalidMessage} payload from OpenCode`); + } + return payload; + }; + + const fetchAgentsSnapshot = () => fetchArraySnapshot('/agent', 'agents snapshot'); + const fetchProvidersSnapshot = () => fetchArraySnapshot('/provider', 'providers snapshot'); + const fetchModelsSnapshot = () => fetchArraySnapshot('/model', 'models snapshot'); + + const setupProxy = (app) => { + registerOpenCodeProxy(app, { + fs, + os, + path, + OPEN_CODE_READY_GRACE_MS: openCodeReadyGraceMs, + LONG_REQUEST_TIMEOUT_MS: longRequestTimeoutMs, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients, + }); + }; + + return { + setOpenCodePort, + waitForOpenCodePort, + buildAugmentedPath, + parseSseDataPayload, + fetchAgentsSnapshot, + fetchProvidersSnapshot, + fetchModelsSnapshot, + setupProxy, + }; +}; diff --git a/packages/web/server/lib/opencode/session-runtime.js b/packages/web/server/lib/opencode/session-runtime.js new file mode 100644 index 00000000..0f181281 --- /dev/null +++ b/packages/web/server/lib/opencode/session-runtime.js @@ -0,0 +1,320 @@ +const SESSION_COOLDOWN_DURATION_MS = 2000; +const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; + +const extractSessionStatusUpdate = (payload) => { + if (!payload || payload.type !== 'session.status') { + return null; + } + + const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; + const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; + const type = typeof info.type === 'string' ? info.type.trim() : ''; + + if (!sessionId || !type) { + return null; + } + + return { + sessionId, + type, + eventId: typeof payload.id === 'string' ? payload.id : '', + attempt: typeof info.attempt === 'number' ? info.attempt : undefined, + message: typeof info.message === 'string' ? info.message : undefined, + next: typeof info.next === 'number' ? info.next : undefined, + }; +}; + +const deriveSessionActivityTransitions = (payload) => { + const update = extractSessionStatusUpdate(payload); + if (!update) { + return []; + } + + if (update.type === 'busy' || update.type === 'retry') { + return [{ sessionId: update.sessionId, phase: 'busy' }]; + } + if (update.type === 'idle') { + return [{ sessionId: update.sessionId, phase: 'cooldown' }]; + } + return []; +}; + +export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => { + const sessionActivityPhases = new Map(); + const sessionActivityCooldowns = new Map(); + const sessionStates = new Map(); + const sessionAttentionStates = new Map(); + + const getOrCreateAttentionState = (sessionId) => { + if (!sessionId || typeof sessionId !== 'string') return null; + + let state = sessionAttentionStates.get(sessionId); + if (!state) { + state = { + needsAttention: false, + lastUserMessageAt: null, + lastStatusChangeAt: Date.now(), + viewedByClients: new Set(), + status: 'idle', + }; + sessionAttentionStates.set(sessionId, state); + } + return state; + }; + + const setSessionActivityPhase = (sessionId, phase) => { + if (!sessionId || typeof sessionId !== 'string') return false; + + const current = sessionActivityPhases.get(sessionId); + if (current?.phase === phase) return false; + if (phase === 'cooldown' && current?.phase !== 'busy') { + return false; + } + + const existingTimer = sessionActivityCooldowns.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + sessionActivityCooldowns.delete(sessionId); + } + + sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); + + if (phase === 'cooldown') { + const timer = setTimeout(() => { + const now = sessionActivityPhases.get(sessionId); + if (now?.phase === 'cooldown') { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); + } + sessionActivityCooldowns.delete(sessionId); + }, SESSION_COOLDOWN_DURATION_MS); + sessionActivityCooldowns.set(sessionId, timer); + } + + return true; + }; + + const updateSessionAttentionStatus = (sessionId, status) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const prevStatus = state.status; + state.status = status; + state.lastStatusChangeAt = Date.now(); + + if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { + if (state.lastUserMessageAt && state.viewedByClients.size === 0) { + state.needsAttention = true; + } + } + }; + + const updateSessionState = (sessionId, status, eventId, metadata = {}) => { + if (!sessionId || typeof sessionId !== 'string') return; + + const now = Date.now(); + const existing = sessionStates.get(sessionId); + const existingAttentionState = sessionAttentionStates.get(sessionId); + if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) { + return; + } + + sessionStates.set(sessionId, { + status, + lastUpdateAt: now, + lastEventId: eventId || `server-${now}`, + metadata: { ...existing?.metadata, ...metadata }, + }); + + updateSessionAttentionStatus(sessionId, status); + const attentionState = sessionAttentionStates.get(sessionId); + const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention; + const clients = getNotificationClients(); + if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) { + const state = sessionStates.get(sessionId); + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: state.lastUpdateAt, + metadata: state.metadata, + needsAttention: attentionState?.needsAttention ?? false, + }, + }); + } catch { + } + } + } + + const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; + setSessionActivityPhase(sessionId, phase); + }; + + const getSessionStateSnapshot = () => { + const result = {}; + const now = Date.now(); + for (const [sessionId, data] of sessionStates) { + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; + result[sessionId] = { + status: data.status, + lastUpdateAt: data.lastUpdateAt, + metadata: data.metadata, + }; + } + return result; + }; + + const getSessionState = (sessionId) => { + if (!sessionId) return null; + return sessionStates.get(sessionId) || null; + }; + + const markSessionViewed = (sessionId, clientId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const wasNeedsAttention = state.needsAttention; + state.viewedByClients.add(clientId); + + if (wasNeedsAttention) { + state.needsAttention = false; + const clients = getNotificationClients(); + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: Date.now(), + metadata: {}, + needsAttention: false, + }, + }); + } catch { + } + } + } + }; + + const markSessionUnviewed = (sessionId, clientId) => { + const state = sessionAttentionStates.get(sessionId); + if (!state) return; + state.viewedByClients.delete(clientId); + }; + + const markUserMessageSent = (sessionId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + state.lastUserMessageAt = Date.now(); + }; + + const getSessionAttentionSnapshot = () => { + const result = {}; + const now = Date.now(); + for (const [sessionId, state] of sessionAttentionStates) { + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; + result[sessionId] = { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0, + }; + } + return result; + }; + + const getSessionAttentionState = (sessionId) => { + if (!sessionId) return null; + const state = sessionAttentionStates.get(sessionId); + if (!state) return null; + return { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0, + }; + }; + + const getSessionActivitySnapshot = () => { + const result = {}; + for (const [sessionId, data] of sessionActivityPhases) { + result[sessionId] = { type: data.phase }; + } + return result; + }; + + const resetAllSessionActivityToIdle = () => { + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); + const now = Date.now(); + for (const [sessionId] of sessionActivityPhases) { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); + } + }; + + const cleanupOldSessionStates = () => { + const now = Date.now(); + for (const [sessionId, data] of sessionStates) { + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { + sessionStates.delete(sessionId); + } + } + for (const [sessionId, state] of sessionAttentionStates) { + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { + sessionAttentionStates.delete(sessionId); + } + } + }; + + const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); + + const processOpenCodeSsePayload = (payload) => { + const transitions = deriveSessionActivityTransitions(payload); + for (const activity of transitions) { + setSessionActivityPhase(activity.sessionId, activity.phase); + } + + if (payload && payload.type === 'session.status') { + const update = extractSessionStatusUpdate(payload); + if (update) { + updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, { + attempt: update.attempt, + message: update.message, + next: update.next, + }); + } + } + }; + + const dispose = () => { + clearInterval(cleanupInterval); + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); + }; + + return { + processOpenCodeSsePayload, + getSessionActivitySnapshot, + getSessionStateSnapshot, + getSessionAttentionSnapshot, + getSessionState, + getSessionAttentionState, + markSessionViewed, + markSessionUnviewed, + markUserMessageSent, + resetAllSessionActivityToIdle, + dispose, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js new file mode 100644 index 00000000..71e2d219 --- /dev/null +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -0,0 +1,602 @@ +export const createSettingsHelpers = (dependencies) => { + const { + normalizePathForPersistence, + normalizeDirectoryPath, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, + } = dependencies; + + const PWA_APP_NAME_MAX_LENGTH = 64; + + const normalizePwaAppName = (value, fallback = '') => { + if (typeof value !== 'string') { + return fallback; + } + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return fallback; + } + return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH); + }; + + const sanitizeSettingsUpdate = (payload) => { + if (!payload || typeof payload !== 'object') { + return {}; + } + + const candidate = payload; + const result = {}; + + if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) { + result.themeId = candidate.themeId; + } + if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { + result.themeVariant = candidate.themeVariant; + } + if (typeof candidate.useSystemTheme === 'boolean') { + result.useSystemTheme = candidate.useSystemTheme; + } + if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) { + result.lightThemeId = candidate.lightThemeId; + } + if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { + result.darkThemeId = candidate.darkThemeId; + } + if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) { + result.splashBgLight = candidate.splashBgLight.trim(); + } + if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) { + result.splashFgLight = candidate.splashFgLight.trim(); + } + if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) { + result.splashBgDark = candidate.splashBgDark.trim(); + } + if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) { + result.splashFgDark = candidate.splashFgDark.trim(); + } + if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { + const normalized = normalizePathForPersistence(candidate.lastDirectory); + if (typeof normalized === 'string' && normalized.length > 0) { + result.lastDirectory = normalized; + } + } + if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { + const normalized = normalizePathForPersistence(candidate.homeDirectory); + if (typeof normalized === 'string' && normalized.length > 0) { + result.homeDirectory = normalized; + } + } + + // Absolute path to the opencode CLI binary (optional override). + // Accept empty-string to clear (we persist an empty string sentinel so the running + // process can reliably drop a previously applied OPENCODE_BINARY override). + if (typeof candidate.opencodeBinary === 'string') { + const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); + result.opencodeBinary = normalized; + } + if (Array.isArray(candidate.projects)) { + const projects = sanitizeProjects(candidate.projects); + if (projects) { + result.projects = projects; + } + } + if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { + result.activeProjectId = candidate.activeProjectId; + } + + if (Array.isArray(candidate.approvedDirectories)) { + result.approvedDirectories = normalizeStringArray( + candidate.approvedDirectories + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + } + if (Array.isArray(candidate.securityScopedBookmarks)) { + result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks); + } + if (Array.isArray(candidate.pinnedDirectories)) { + result.pinnedDirectories = normalizeStringArray( + candidate.pinnedDirectories + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + } + + + if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) { + result.uiFont = candidate.uiFont; + } + if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) { + result.monoFont = candidate.monoFont; + } + if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) { + result.markdownDisplayMode = candidate.markdownDisplayMode; + } + if (typeof candidate.githubClientId === 'string') { + const trimmed = candidate.githubClientId.trim(); + if (trimmed.length > 0) { + result.githubClientId = trimmed; + } + } + if (typeof candidate.githubScopes === 'string') { + const trimmed = candidate.githubScopes.trim(); + if (trimmed.length > 0) { + result.githubScopes = trimmed; + } + } + if (typeof candidate.showReasoningTraces === 'boolean') { + result.showReasoningTraces = candidate.showReasoningTraces; + } + if (typeof candidate.showTextJustificationActivity === 'boolean') { + result.showTextJustificationActivity = candidate.showTextJustificationActivity; + } + if (typeof candidate.showDeletionDialog === 'boolean') { + result.showDeletionDialog = candidate.showDeletionDialog; + } + if (typeof candidate.nativeNotificationsEnabled === 'boolean') { + result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; + } + if (typeof candidate.notificationMode === 'string') { + const mode = candidate.notificationMode.trim(); + if (mode === 'always' || mode === 'hidden-only') { + result.notificationMode = mode; + } + } + if (typeof candidate.notifyOnSubtasks === 'boolean') { + result.notifyOnSubtasks = candidate.notifyOnSubtasks; + } + if (typeof candidate.notifyOnCompletion === 'boolean') { + result.notifyOnCompletion = candidate.notifyOnCompletion; + } + if (typeof candidate.notifyOnError === 'boolean') { + result.notifyOnError = candidate.notifyOnError; + } + if (typeof candidate.notifyOnQuestion === 'boolean') { + result.notifyOnQuestion = candidate.notifyOnQuestion; + } + if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') { + result.notificationTemplates = candidate.notificationTemplates; + } + if (typeof candidate.summarizeLastMessage === 'boolean') { + result.summarizeLastMessage = candidate.summarizeLastMessage; + } + if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) { + result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold)); + } + if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) { + result.summaryLength = Math.max(10, Math.round(candidate.summaryLength)); + } + if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) { + result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength)); + } + if (typeof candidate.usageAutoRefresh === 'boolean') { + result.usageAutoRefresh = candidate.usageAutoRefresh; + } + if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { + result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs))); + } + if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { + result.usageDisplayMode = candidate.usageDisplayMode; + } + if (Array.isArray(candidate.usageDropdownProviders)) { + result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders); + } + if (typeof candidate.autoDeleteEnabled === 'boolean') { + result.autoDeleteEnabled = candidate.autoDeleteEnabled; + } + if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { + const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); + result.autoDeleteAfterDays = normalizedDays; + } + if (candidate.tunnelBootstrapTtlMs === null) { + result.tunnelBootstrapTtlMs = null; + } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) { + result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs); + } + if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) { + result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs); + } + if (typeof candidate.tunnelProvider === 'string') { + const provider = normalizeTunnelProvider(candidate.tunnelProvider); + if (provider) { + result.tunnelProvider = provider; + } + } + if (typeof candidate.tunnelMode === 'string') { + result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode); + } + if (candidate.managedLocalTunnelConfigPath === null) { + result.managedLocalTunnelConfigPath = null; + } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') { + const trimmed = candidate.managedLocalTunnelConfigPath.trim(); + result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null; + } + if (typeof candidate.managedRemoteTunnelHostname === 'string') { + const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname); + result.managedRemoteTunnelHostname = hostname; + } + if (candidate.managedRemoteTunnelToken === null) { + result.managedRemoteTunnelToken = null; + } else if (typeof candidate.managedRemoteTunnelToken === 'string') { + result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim(); + } + const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets); + if (managedRemoteTunnelPresets) { + result.managedRemoteTunnelPresets = managedRemoteTunnelPresets; + } + const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens); + if (managedRemoteTunnelPresetTokens) { + result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens; + } + if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') { + const id = candidate.managedRemoteTunnelSelectedPresetId.trim(); + result.managedRemoteTunnelSelectedPresetId = id || undefined; + } + + const typography = sanitizeTypographySizesPartial(candidate.typographySizes); + if (typography) { + result.typographySizes = typography; + } + + if (typeof candidate.defaultModel === 'string') { + const trimmed = candidate.defaultModel.trim(); + result.defaultModel = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultVariant === 'string') { + const trimmed = candidate.defaultVariant.trim(); + result.defaultVariant = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultAgent === 'string') { + const trimmed = candidate.defaultAgent.trim(); + result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultGitIdentityId === 'string') { + const trimmed = candidate.defaultGitIdentityId.trim(); + result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.queueModeEnabled === 'boolean') { + result.queueModeEnabled = candidate.queueModeEnabled; + } + if (typeof candidate.autoCreateWorktree === 'boolean') { + result.autoCreateWorktree = candidate.autoCreateWorktree; + } + if (typeof candidate.gitmojiEnabled === 'boolean') { + result.gitmojiEnabled = candidate.gitmojiEnabled; + } + if (typeof candidate.zenModel === 'string') { + const trimmed = candidate.zenModel.trim(); + result.zenModel = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.gitProviderId === 'string') { + const trimmed = candidate.gitProviderId.trim(); + result.gitProviderId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.gitModelId === 'string') { + const trimmed = candidate.gitModelId.trim(); + result.gitModelId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.pwaAppName === 'string') { + result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); + } + if (typeof candidate.toolCallExpansion === 'string') { + const mode = candidate.toolCallExpansion.trim(); + if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { + result.toolCallExpansion = mode; + } + } + if (typeof candidate.inputSpellcheckEnabled === 'boolean') { + result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; + } + if (typeof candidate.showToolFileIcons === 'boolean') { + result.showToolFileIcons = candidate.showToolFileIcons; + } + if (typeof candidate.showExpandedBashTools === 'boolean') { + result.showExpandedBashTools = candidate.showExpandedBashTools; + } + if (typeof candidate.showExpandedEditTools === 'boolean') { + result.showExpandedEditTools = candidate.showExpandedEditTools; + } + if (typeof candidate.chatRenderMode === 'string') { + const mode = candidate.chatRenderMode.trim(); + if (mode === 'sorted' || mode === 'live') { + result.chatRenderMode = mode; + } + } + if (typeof candidate.activityRenderMode === 'string') { + const mode = candidate.activityRenderMode.trim(); + if (mode === 'collapsed' || mode === 'summary') { + result.activityRenderMode = mode; + } + } + if (typeof candidate.mermaidRenderingMode === 'string') { + const mode = candidate.mermaidRenderingMode.trim(); + if (mode === 'svg' || mode === 'ascii') { + result.mermaidRenderingMode = mode; + } + } + if (typeof candidate.userMessageRenderingMode === 'string') { + const mode = candidate.userMessageRenderingMode.trim(); + if (mode === 'markdown' || mode === 'plain') { + result.userMessageRenderingMode = mode; + } + } + if (typeof candidate.stickyUserHeader === 'boolean') { + result.stickyUserHeader = candidate.stickyUserHeader; + } + if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { + result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); + } + if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) { + result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize))); + } + if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) { + result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding))); + } + if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) { + result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius))); + } + if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { + result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset))); + } + + const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); + if (favoriteModels) { + result.favoriteModels = favoriteModels; + } + + const recentModels = sanitizeModelRefs(candidate.recentModels, 16); + if (recentModels) { + result.recentModels = recentModels; + } + if (typeof candidate.diffLayoutPreference === 'string') { + const mode = candidate.diffLayoutPreference.trim(); + if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { + result.diffLayoutPreference = mode; + } + } + if (typeof candidate.diffViewMode === 'string') { + const mode = candidate.diffViewMode.trim(); + if (mode === 'single' || mode === 'stacked') { + result.diffViewMode = mode; + } + } + if (typeof candidate.directoryShowHidden === 'boolean') { + result.directoryShowHidden = candidate.directoryShowHidden; + } + if (typeof candidate.filesViewShowGitignored === 'boolean') { + result.filesViewShowGitignored = candidate.filesViewShowGitignored; + } + if (typeof candidate.openInAppId === 'string') { + const trimmed = candidate.openInAppId.trim(); + if (trimmed.length > 0) { + result.openInAppId = trimmed; + } + } + + // Message limit — single setting for fetch / trim / Load More chunk + if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { + result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit))); + } + + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); + if (skillCatalogs) { + result.skillCatalogs = skillCatalogs; + } + + // Usage model selections - which models appear in dropdown + if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') { + const sanitized = {}; + for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) { + if (typeof providerId === 'string' && Array.isArray(models)) { + const validModels = models.filter((m) => typeof m === 'string' && m.length > 0); + if (validModels.length > 0) { + sanitized[providerId] = validModels; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageSelectedModels = sanitized; + } + } + + // Usage page collapsed families - for "Other Models" section + if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') { + const sanitized = {}; + for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) { + if (typeof providerId === 'string' && Array.isArray(families)) { + const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); + if (validFamilies.length > 0) { + sanitized[providerId] = validFamilies; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageCollapsedFamilies = sanitized; + } + } + + // Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed) + if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') { + const sanitized = {}; + for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) { + if (typeof providerId === 'string' && Array.isArray(families)) { + const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); + if (validFamilies.length > 0) { + sanitized[providerId] = validFamilies; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageExpandedFamilies = sanitized; + } + } + + // Custom model groups configuration + if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') { + const sanitized = {}; + for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) { + if (typeof providerId !== 'string') continue; + + const providerConfig = {}; + + // customGroups: array of {id, label, models, order} + if (Array.isArray(config.customGroups)) { + const validGroups = config.customGroups + .filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string') + .map((g) => ({ + id: g.id.slice(0, 64), + label: g.label.slice(0, 128), + models: Array.isArray(g.models) + ? g.models.filter((m) => typeof m === 'string').slice(0, 500) + : [], + order: typeof g.order === 'number' ? g.order : 0, + })); + if (validGroups.length > 0) { + providerConfig.customGroups = validGroups; + } + } + + // modelAssignments: Record + if (config.modelAssignments && typeof config.modelAssignments === 'object') { + const assignments = {}; + for (const [model, groupId] of Object.entries(config.modelAssignments)) { + if (typeof model === 'string' && typeof groupId === 'string') { + assignments[model] = groupId; + } + } + if (Object.keys(assignments).length > 0) { + providerConfig.modelAssignments = assignments; + } + } + + // renamedGroups: Record + if (config.renamedGroups && typeof config.renamedGroups === 'object') { + const renamed = {}; + for (const [groupId, label] of Object.entries(config.renamedGroups)) { + if (typeof groupId === 'string' && typeof label === 'string') { + renamed[groupId] = label.slice(0, 128); + } + } + if (Object.keys(renamed).length > 0) { + providerConfig.renamedGroups = renamed; + } + } + + if (Object.keys(providerConfig).length > 0) { + sanitized[providerId] = providerConfig; + } + } + if (Object.keys(sanitized).length > 0) { + result.usageModelGroups = sanitized; + } + } + + // Usage reporting opt-out (default: true/enabled) + if (typeof candidate.reportUsage === 'boolean') { + result.reportUsage = candidate.reportUsage; + } + + return result; + }; + + const mergePersistedSettings = (current, changes) => { + const baseApproved = Array.isArray(changes.approvedDirectories) + ? changes.approvedDirectories + : Array.isArray(current.approvedDirectories) + ? current.approvedDirectories + : []; + + const additionalApproved = []; + if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) { + additionalApproved.push(changes.lastDirectory); + } + if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { + additionalApproved.push(changes.homeDirectory); + } + const projectEntries = Array.isArray(changes.projects) + ? changes.projects + : Array.isArray(current.projects) + ? current.projects + : []; + projectEntries.forEach((project) => { + if (project && typeof project.path === 'string' && project.path.length > 0) { + additionalApproved.push(project.path); + } + }); + const approvedSource = [...baseApproved, ...additionalApproved]; + + const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) + ? changes.securityScopedBookmarks + : Array.isArray(current.securityScopedBookmarks) + ? current.securityScopedBookmarks + : []; + + const nextTypographySizes = changes.typographySizes + ? { + ...(current.typographySizes || {}), + ...changes.typographySizes + } + : current.typographySizes; + + const next = { + ...current, + ...changes, + approvedDirectories: Array.from( + new Set( + approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ), + securityScopedBookmarks: Array.from( + new Set( + baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ), + typographySizes: nextTypographySizes + }; + + return next; + }; + + const formatSettingsResponse = (settings) => { + const sanitized = sanitizeSettingsUpdate(settings); + delete sanitized.managedRemoteTunnelToken; + const approved = normalizeStringArray(settings.approvedDirectories); + const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); + const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; + const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); + + return { + ...sanitized, + hasManagedRemoteTunnelToken, + ...(pwaAppName ? { pwaAppName } : {}), + approvedDirectories: approved, + securityScopedBookmarks: bookmarks, + pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), + typographySizes: sanitizeTypographySizesPartial(settings.typographySizes), + showReasoningTraces: + typeof settings.showReasoningTraces === 'boolean' + ? settings.showReasoningTraces + : typeof sanitized.showReasoningTraces === 'boolean' + ? sanitized.showReasoningTraces + : false + }; + }; + + return { + normalizePwaAppName, + sanitizeSettingsUpdate, + mergePersistedSettings, + formatSettingsResponse, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js new file mode 100644 index 00000000..5c5c6c9c --- /dev/null +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -0,0 +1,428 @@ +export const createSettingsNormalizationRuntime = (dependencies) => { + const { + os, + path, + processLike, + tunnelBootstrapTtlDefaultMs, + tunnelBootstrapTtlMinMs, + tunnelBootstrapTtlMaxMs, + tunnelSessionTtlDefaultMs, + tunnelSessionTtlMinMs, + tunnelSessionTtlMaxMs, + } = dependencies; + + const normalizeDirectoryPath = (value) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (trimmed === '~') { + return os.homedir(); + } + + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; + }; + + const normalizePathForPersistence = (value) => { + if (typeof value !== 'string') { + return value; + } + + const normalized = normalizeDirectoryPath(value); + if (typeof normalized !== 'string') { + return normalized; + } + + const trimmed = normalized.trim(); + if (!trimmed) { + return trimmed; + } + + if (processLike.platform !== 'win32') { + return trimmed; + } + + return trimmed.replace(/\//g, '\\'); + }; + + const areStringArraysEqual = (a, b) => { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }; + + const normalizeStringArray = (input) => { + if (!Array.isArray(input)) { + return []; + } + return Array.from( + new Set( + input.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ); + }; + + const sanitizeProjects = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; + const normalizeIconBackground = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null; + }; + + const result = []; + const seenIds = new Set(); + const seenPaths = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const candidate = entry; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; + const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : ''; + const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object' + ? candidate.iconImage + : null; + const iconBackground = normalizeIconBackground(candidate.iconBackground); + const color = typeof candidate.color === 'string' ? candidate.color.trim() : ''; + const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; + const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) + ? Number(candidate.lastOpenedAt) + : null; + + if (!id || !normalizedPath) continue; + if (seenIds.has(id)) continue; + if (seenPaths.has(normalizedPath)) continue; + + seenIds.add(id); + seenPaths.add(normalizedPath); + + const project = { + id, + path: normalizedPath, + ...(label ? { label } : {}), + ...(icon ? { icon } : {}), + ...(iconBackground ? { iconBackground } : {}), + ...(color ? { color } : {}), + ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), + ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), + }; + + if (candidate.iconImage === null) { + project.iconImage = null; + } else if (iconImage) { + const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : ''; + const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt) + ? Math.max(0, Math.round(iconImage.updatedAt)) + : 0; + const source = iconImage.source === 'custom' || iconImage.source === 'auto' + ? iconImage.source + : null; + if (mime && updatedAt > 0 && source) { + project.iconImage = { mime, updatedAt, source }; + } + } + + if (candidate.iconBackground === null) { + project.iconBackground = null; + } + + if (typeof candidate.sidebarCollapsed === 'boolean') { + project.sidebarCollapsed = candidate.sidebarCollapsed; + } + + result.push(project); + } + + return result; + }; + + const normalizeSettingsPaths = (input) => { + const settings = input && typeof input === 'object' ? input : {}; + let next = settings; + let changed = false; + + const ensureNext = () => { + if (next === settings) { + next = { ...settings }; + } + }; + + const normalizePathField = (key) => { + if (typeof settings[key] !== 'string' || settings[key].length === 0) { + return; + } + const normalized = normalizePathForPersistence(settings[key]); + if (normalized !== settings[key]) { + ensureNext(); + next[key] = normalized; + changed = true; + } + }; + + const normalizePathArrayField = (key) => { + if (!Array.isArray(settings[key])) { + return; + } + + const normalized = normalizeStringArray( + settings[key] + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + + if (!areStringArraysEqual(normalized, settings[key])) { + ensureNext(); + next[key] = normalized; + changed = true; + } + }; + + normalizePathField('lastDirectory'); + normalizePathField('homeDirectory'); + normalizePathArrayField('approvedDirectories'); + normalizePathArrayField('pinnedDirectories'); + + if (Array.isArray(settings.projects)) { + const normalizedProjects = sanitizeProjects(settings.projects) || []; + if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) { + ensureNext(); + next.projects = normalizedProjects; + changed = true; + } + } + + return { settings: next, changed }; + }; + + const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); + + const normalizeTunnelBootstrapTtlMs = (value) => { + if (value === null) { + return null; + } + if (!Number.isFinite(value)) { + return tunnelBootstrapTtlDefaultMs; + } + return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs); + }; + + const normalizeTunnelSessionTtlMs = (value) => { + if (!Number.isFinite(value)) { + return tunnelSessionTtlDefaultMs; + } + return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs); + }; + + const normalizeManagedRemoteTunnelHostname = (value) => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const parsed = (() => { + try { + if (trimmed.includes('://')) { + return new URL(trimmed); + } + return new URL(`https://${trimmed}`); + } catch { + return null; + } + })(); + + const hostname = parsed?.hostname?.trim().toLowerCase() || ''; + if (!hostname) { + return undefined; + } + return hostname; + }; + + const normalizeManagedRemoteTunnelPresets = (value) => { + if (!Array.isArray(value)) { + return undefined; + } + + const result = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname); + if (!id || !name || !hostname) continue; + if (seenIds.has(id) || seenHostnames.has(hostname)) continue; + seenIds.add(id); + seenHostnames.add(hostname); + result.push({ id, name, hostname }); + } + + return result; + }; + + const normalizeManagedRemoteTunnelPresetTokens = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result = {}; + for (const [rawId, rawToken] of Object.entries(value)) { + const id = typeof rawId === 'string' ? rawId.trim() : ''; + const token = typeof rawToken === 'string' ? rawToken.trim() : ''; + if (!id || !token) { + continue; + } + result[id] = token; + } + + return Object.keys(result).length > 0 ? result : undefined; + }; + + const isUnsafeSkillRelativePath = (value) => { + if (typeof value !== 'string' || value.length === 0) { + return true; + } + + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized)) { + return true; + } + + return normalized.split('/').some((segment) => segment === '..'); + }; + + const sanitizeTypographySizesPartial = (input) => { + if (!input || typeof input !== 'object') { + return undefined; + } + const candidate = input; + const result = {}; + let populated = false; + + const assign = (key) => { + if (typeof candidate[key] === 'string' && candidate[key].length > 0) { + result[key] = candidate[key]; + populated = true; + } + }; + + assign('markdown'); + assign('code'); + assign('uiHeader'); + assign('uiLabel'); + assign('meta'); + assign('micro'); + + return populated ? result : undefined; + }; + + const sanitizeModelRefs = (input, limit) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : ''; + const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : ''; + if (!providerID || !modelID) continue; + const key = `${providerID}/${modelID}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ providerID, modelID }); + if (result.length >= limit) break; + } + + return result; + }; + + const sanitizeSkillCatalogs = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + const label = typeof entry.label === 'string' ? entry.label.trim() : ''; + const source = typeof entry.source === 'string' ? entry.source.trim() : ''; + const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : ''; + const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : ''; + + if (!id || !label || !source) continue; + if (seen.has(id)) continue; + seen.add(id); + + result.push({ + id, + label, + source, + ...(subpath ? { subpath } : {}), + ...(gitIdentityId ? { gitIdentityId } : {}), + }); + } + + return result; + }; + + return { + normalizeDirectoryPath, + normalizePathForPersistence, + normalizeSettingsPaths, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + isUnsafeSkillRelativePath, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js new file mode 100644 index 00000000..a57124ec --- /dev/null +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -0,0 +1,439 @@ +const DEFAULT_NOTIFICATION_TEMPLATES = { + completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, + error: { title: 'Tool error', message: '{last_message}' }, + question: { title: 'Input needed', message: '{last_message}' }, + subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, +}; + +const ensureNotificationTemplateShape = (templates) => { + const input = templates && typeof templates === 'object' ? templates : {}; + let changed = false; + const next = {}; + + for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) { + const currentEntry = input[event]; + const base = DEFAULT_NOTIFICATION_TEMPLATES[event]; + const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title; + const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message; + if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') { + changed = true; + } + next[event] = { title: currentTitle, message: currentMessage }; + } + + return { templates: next, changed }; +}; + +export const createSettingsRuntime = (deps) => { + const { + fsPromises, + path, + crypto, + SETTINGS_FILE_PATH, + sanitizeProjects, + sanitizeSettingsUpdate, + mergePersistedSettings, + normalizeSettingsPaths, + normalizeStringArray, + formatSettingsResponse, + resolveDirectoryCandidate, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, + } = deps; + + let persistSettingsLock = Promise.resolve(); + + const readSettingsFromDisk = async () => { + try { + const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed; + } + return {}; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return {}; + } + console.warn('Failed to read settings file:', error); + return {}; + } + }; + + const writeSettingsToDisk = async (settings) => { + try { + await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch (error) { + console.warn('Failed to write settings file:', error); + throw error; + } + }; + + const validateProjectEntries = async (projects) => { + console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`); + + if (!Array.isArray(projects)) { + console.warn('[validateProjectEntries] Input is not an array, returning empty'); + return []; + } + + const validations = projects.map(async (project) => { + if (!project || typeof project.path !== 'string' || project.path.length === 0) { + console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project); + return null; + } + try { + const stats = await fsPromises.stat(project.path); + if (!stats.isDirectory()) { + console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`); + return null; + } + return project; + } catch (error) { + const err = error; + console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`); + if (err && typeof err === 'object' && err.code === 'ENOENT') { + console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`); + return null; + } + console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`); + return project; + } + }); + + const results = (await Promise.all(validations)).filter((p) => p !== null); + + console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`); + return results; + }; + + const migrateSettingsFromLegacyLastDirectory = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const now = Date.now(); + + const sanitizedProjects = sanitizeProjects(settings.projects) || []; + let nextProjects = sanitizedProjects; + let nextActiveProjectId = + typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined; + + let changed = false; + + if (nextProjects.length === 0) { + const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : ''; + const candidate = legacy ? resolveDirectoryCandidate(legacy) : null; + + if (candidate) { + try { + const stats = await fsPromises.stat(candidate); + if (stats.isDirectory()) { + const id = crypto.randomUUID(); + nextProjects = [ + { + id, + path: candidate, + addedAt: now, + lastOpenedAt: now, + }, + ]; + nextActiveProjectId = id; + changed = true; + } + } catch { + // ignore invalid lastDirectory + } + } + } + + if (nextProjects.length > 0) { + const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null; + if (!active) { + nextActiveProjectId = nextProjects[0].id; + changed = true; + } + } else if (nextActiveProjectId) { + nextActiveProjectId = undefined; + changed = true; + } + + if (!changed) { + return { settings, changed: false }; + } + + const merged = mergePersistedSettings(settings, { + ...settings, + projects: nextProjects, + ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }), + }); + + return { settings: merged, changed: true }; + }; + + const migrateSettingsFromLegacyThemePreferences = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + + const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : ''; + const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : ''; + + const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0; + const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0; + + if (hasLight && hasDark) { + return { settings, changed: false }; + } + + const defaultLight = 'flexoki-light'; + const defaultDark = 'flexoki-dark'; + + let nextLightThemeId = hasLight ? settings.lightThemeId : undefined; + let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined; + + if (!hasLight) { + if (themeId && themeVariant === 'light') { + nextLightThemeId = themeId; + } else { + nextLightThemeId = defaultLight; + } + } + + if (!hasDark) { + if (themeId && themeVariant === 'dark') { + nextDarkThemeId = themeId; + } else { + nextDarkThemeId = defaultDark; + } + } + + const merged = mergePersistedSettings(settings, { + ...settings, + ...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}), + ...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}), + }); + + return { settings: merged, changed: true }; + }; + + const migrateSettingsFromLegacyCollapsedProjects = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const collapsed = Array.isArray(settings.collapsedProjects) + ? normalizeStringArray(settings.collapsedProjects) + : []; + + if (collapsed.length === 0 || !Array.isArray(settings.projects)) { + if (collapsed.length === 0) { + return { settings, changed: false }; + } + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + + const set = new Set(collapsed); + const projects = sanitizeProjects(settings.projects) || []; + let changed = false; + + const nextProjects = projects.map((project) => { + const shouldCollapse = set.has(project.id); + if (project.sidebarCollapsed !== shouldCollapse) { + changed = true; + return { ...project, sidebarCollapsed: shouldCollapse }; + } + return project; + }); + + if (!changed) { + if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) { + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + return { settings, changed: false }; + } + + const next = { ...settings, projects: nextProjects }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + }; + + const migrateSettingsNotificationDefaults = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + let changed = false; + const next = { ...settings }; + + if (typeof settings.notifyOnSubtasks !== 'boolean') { + next.notifyOnSubtasks = true; + changed = true; + } + if (typeof settings.notifyOnCompletion !== 'boolean') { + next.notifyOnCompletion = true; + changed = true; + } + if (typeof settings.notifyOnError !== 'boolean') { + next.notifyOnError = true; + changed = true; + } + if (typeof settings.notifyOnQuestion !== 'boolean') { + next.notifyOnQuestion = true; + changed = true; + } + + const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates); + if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') { + next.notificationTemplates = templates; + changed = true; + } + + return { settings: changed ? next : settings, changed }; + }; + + const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const next = { ...settings }; + let changed = false; + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) { + next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) { + if (next.namedTunnelToken === null) { + next.managedRemoteTunnelToken = null; + } else if (typeof next.namedTunnelToken === 'string') { + next.managedRemoteTunnelToken = next.namedTunnelToken.trim(); + } + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) { + next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) { + next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) { + const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string' + ? next.namedTunnelSelectedPresetId.trim() + : ''; + if (selectedPresetId) { + next.managedRemoteTunnelSelectedPresetId = selectedPresetId; + } + changed = true; + } + + const legacyKeys = [ + 'namedTunnelHostname', + 'namedTunnelToken', + 'namedTunnelPresets', + 'namedTunnelPresetTokens', + 'namedTunnelSelectedPresetId', + ]; + for (const key of legacyKeys) { + if (Object.prototype.hasOwnProperty.call(next, key)) { + delete next[key]; + changed = true; + } + } + + return { settings: changed ? next : settings, changed }; + }; + + const readSettingsFromDiskMigrated = async () => { + const current = await readSettingsFromDisk(); + const migration1 = await migrateSettingsFromLegacyLastDirectory(current); + const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings); + const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings); + const migration4 = await migrateSettingsNotificationDefaults(migration3.settings); + const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings); + const migration6 = normalizeSettingsPaths(migration5.settings); + if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) { + await writeSettingsToDisk(migration6.settings); + } + return migration6.settings; + }; + + const persistSettings = async (changes) => { + persistSettingsLock = persistSettingsLock.then(async () => { + console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2)); + const current = await readSettingsFromDisk(); + console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A'); + const sanitized = sanitizeSettingsUpdate(changes); + let next = mergePersistedSettings(current, sanitized); + + const normalizedState = normalizeSettingsPaths(next); + if (normalizedState.changed) { + next = normalizedState.settings; + } + + if (Array.isArray(next.projects)) { + console.log(`[persistSettings] Validating ${next.projects.length} projects...`); + const validated = await validateProjectEntries(next.projects); + console.log(`[persistSettings] After validation: ${validated.length} projects remain`); + next = { ...next, projects: validated }; + } + + if (Array.isArray(next.projects) && next.projects.length > 0) { + const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : ''; + const active = next.projects.find((project) => project.id === activeId) || null; + if (!active) { + console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`); + next = { ...next, activeProjectId: next.projects[0].id }; + } + } else if (next.activeProjectId) { + console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`); + next = { ...next, activeProjectId: undefined }; + } + + if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) { + await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets); + } + + if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) { + const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry])); + const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens) + .map(([presetId, token]) => { + const preset = presetsById.get(presetId); + if (!preset || typeof token !== 'string' || token.trim().length === 0) { + return null; + } + return { + id: preset.id, + name: preset.name, + hostname: preset.hostname, + token: token.trim(), + }; + }) + .filter(Boolean); + + for (const update of updates) { + await upsertManagedRemoteTunnelToken(update); + } + } + + await writeSettingsToDisk(next); + console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); + return formatSettingsResponse(next); + }); + + return persistSettingsLock; + }; + + return { + readSettingsFromDisk, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + persistSettings, + }; +}; diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js new file mode 100644 index 00000000..1350e2eb --- /dev/null +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -0,0 +1,114 @@ +export const createGracefulShutdownRuntime = (dependencies) => { + const { + process, + shutdownTimeoutMs, + getExitOnShutdown, + getIsShuttingDown, + setIsShuttingDown, + syncToHmrState, + openCodeWatcherRuntime, + sessionRuntime, + getHealthCheckInterval, + clearHealthCheckInterval, + getTerminalRuntime, + setTerminalRuntime, + shouldSkipOpenCodeStop, + getOpenCodePort, + getOpenCodeProcess, + setOpenCodeProcess, + killProcessOnPort, + getServer, + getUiAuthController, + setUiAuthController, + getActiveTunnelController, + setActiveTunnelController, + tunnelAuthController, + } = dependencies; + + const gracefulShutdown = async (options = {}) => { + if (getIsShuttingDown()) return; + + setIsShuttingDown(true); + syncToHmrState(); + console.log('Starting graceful shutdown...'); + const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown(); + + openCodeWatcherRuntime.stop(); + sessionRuntime.dispose(); + + const healthCheckInterval = getHealthCheckInterval(); + if (healthCheckInterval) { + clearHealthCheckInterval(healthCheckInterval); + } + + const terminalRuntime = getTerminalRuntime(); + if (terminalRuntime) { + try { + await terminalRuntime.shutdown(); + } catch { + } finally { + setTerminalRuntime(null); + } + } + + if (!shouldSkipOpenCodeStop()) { + const portToKill = getOpenCodePort(); + const openCodeProcess = getOpenCodeProcess(); + + if (openCodeProcess) { + console.log('Stopping OpenCode process...'); + try { + openCodeProcess.close(); + } catch (error) { + console.warn('Error closing OpenCode process:', error); + } + setOpenCodeProcess(null); + } + + killProcessOnPort(portToKill); + } else { + console.log('Skipping OpenCode shutdown (external server)'); + } + + const server = getServer(); + if (server) { + await Promise.race([ + new Promise((resolve) => { + server.close(() => { + console.log('HTTP server closed'); + resolve(); + }); + }), + new Promise((resolve) => { + setTimeout(() => { + console.warn('Server close timeout reached, forcing shutdown'); + resolve(); + }, shutdownTimeoutMs); + }), + ]); + } + + const uiAuthController = getUiAuthController(); + if (uiAuthController) { + uiAuthController.dispose(); + setUiAuthController(null); + } + + const activeTunnelController = getActiveTunnelController(); + if (activeTunnelController) { + console.log('Stopping active tunnel...'); + activeTunnelController.stop(); + setActiveTunnelController(null); + tunnelAuthController.clearActiveTunnel(); + } + + console.log('Graceful shutdown complete'); + if (exitProcess) { + process.exit(0); + } + }; + + return { + gracefulShutdown, + }; +}; diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js new file mode 100644 index 00000000..69ef2727 --- /dev/null +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -0,0 +1,707 @@ +export const registerSkillRoutes = (app, dependencies) => { + const { + fs, + path, + os, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + readSettingsFromDisk, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + getProfiles, + getProfile, + } = dependencies; + + const findWorktreeRootForSkills = (workingDirectory) => { + if (!workingDirectory) return null; + let current = path.resolve(workingDirectory); + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } + }; + + const getSkillProjectAncestors = (workingDirectory) => { + if (!workingDirectory) return []; + const result = []; + let current = path.resolve(workingDirectory); + const stop = findWorktreeRootForSkills(workingDirectory) || current; + while (true) { + result.push(current); + if (current === stop) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return result; + }; + + const isPathInside = (candidatePath, parentPath) => { + if (!candidatePath || !parentPath) return false; + const normalizedCandidate = path.resolve(candidatePath); + const normalizedParent = path.resolve(parentPath); + return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); + }; + + const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => { + const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : ''; + const home = os.homedir(); + const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) + ? 'agents' + : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) + ? 'claude' + : 'opencode'; + + const projectAncestors = getSkillProjectAncestors(workingDirectory); + const isProjectScoped = projectAncestors.some((ancestor) => { + const candidates = [ + path.join(ancestor, '.opencode'), + path.join(ancestor, '.claude', 'skills'), + path.join(ancestor, '.agents', 'skills'), + ]; + return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); + }); + + if (isProjectScoped) { + return { scope: SKILL_SCOPE.PROJECT, source }; + } + + const userRoots = [ + path.join(home, '.config', 'opencode'), + path.join(home, '.opencode'), + path.join(home, '.claude', 'skills'), + path.join(home, '.agents', 'skills'), + process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, + ].filter(Boolean); + + if (userRoots.some((root) => isPathInside(resolvedPath, root))) { + return { scope: SKILL_SCOPE.USER, source }; + } + + return { scope: SKILL_SCOPE.USER, source }; + }; + + const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => { + if (!getOpenCodePort()) { + return null; + } + + try { + const url = new URL(buildOpenCodeUrl('/skill', '')); + if (workingDirectory) { + url.searchParams.set('directory', workingDirectory); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + return null; + } + + return payload + .map((item) => { + const name = typeof item?.name === 'string' ? item.name.trim() : ''; + const location = typeof item?.location === 'string' ? item.location : ''; + const description = typeof item?.description === 'string' ? item.description : ''; + if (!name || !location) { + return null; + } + const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory); + return { + name, + path: location, + scope: inferred.scope, + source: inferred.source, + description, + }; + }) + .filter(Boolean); + } catch { + return null; + } + }; + + const listGitIdentitiesForResponse = () => { + try { + const profiles = getProfiles(); + return profiles.map((p) => ({ id: p.id, name: p.name })); + } catch { + return []; + } + }; + + const resolveGitIdentity = (profileId) => { + if (!profileId) { + return null; + } + try { + const profile = getProfile(profileId); + const sshKey = profile?.sshKey; + if (typeof sshKey === 'string' && sshKey.trim()) { + return { sshKey: sshKey.trim() }; + } + } catch { + // ignore + } + return null; + }; + + app.get('/api/config/skills', async (req, res) => { + try { + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory); + + const enrichedSkills = skills.map((skill) => { + const sources = getSkillSources(skill.name, directory, skill); + return { + ...skill, + sources + }; + }); + + res.json({ skills: enrichedSkills }); + } catch (error) { + console.error('Failed to list skills:', error); + res.status(500).json({ error: 'Failed to list skills' }); + } + }); + + app.get('/api/config/skills/catalog', async (req, res) => { + try { + const { error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + + const curatedSources = getCuratedSkillsSources(); + const settings = await readSettingsFromDisk(); + const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; + + const customSources = customSourcesRaw.map((entry) => ({ + id: entry.id, + label: entry.label, + description: entry.source, + source: entry.source, + defaultSubpath: entry.subpath, + gitIdentityId: entry.gitIdentityId, + })); + + const sources = [...curatedSources, ...customSources]; + const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); + + res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); + } catch (error) { + console.error('Failed to load skills catalog:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); + } + }); + + app.get('/api/config/skills/catalog/source', async (req, res) => { + try { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } }); + } + + const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null; + if (!sourceId) { + return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } }); + } + + const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; + + const curatedSources = getCuratedSkillsSources(); + const settings = await readSettingsFromDisk(); + const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; + + const customSources = customSourcesRaw.map((entry) => ({ + id: entry.id, + label: entry.label, + description: entry.source, + source: entry.source, + defaultSubpath: entry.subpath, + gitIdentityId: entry.gitIdentityId, + })); + + const sources = [...curatedSources, ...customSources]; + const src = sources.find((entry) => entry.id === sourceId); + + if (!src) { + return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } }); + } + + const discovered = directory + ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory)) + : []; + const installedByName = new Map(discovered.map((s) => [s.name, s])); + + if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { + const scanned = await scanClawdHubPage({ cursor: cursor || null }); + if (!scanned.ok) { + return res.status(500).json({ ok: false, error: scanned.error }); + } + + const items = (scanned.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + ...item, + sourceId: src.id, + installed: installed + ? { isInstalled: true, scope: installed.scope, source: installed.source } + : { isInstalled: false }, + }; + }); + + return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); + } + + const parsed = parseSkillRepoSource(src.source); + if (!parsed.ok) { + return res.status(400).json({ ok: false, error: parsed.error }); + } + + const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null; + const cacheKey = getCacheKey({ + normalizedRepo: parsed.normalizedRepo, + subpath: effectiveSubpath || '', + identityId: src.gitIdentityId || '', + }); + + let scanResult = !refresh ? getCachedScan(cacheKey) : null; + if (!scanResult) { + const scanned = await scanSkillsRepository({ + source: src.source, + subpath: src.defaultSubpath, + defaultSubpath: src.defaultSubpath, + identity: resolveGitIdentity(src.gitIdentityId), + }); + + if (!scanned.ok) { + return res.status(500).json({ ok: false, error: scanned.error }); + } + + scanResult = scanned; + setCachedScan(cacheKey, scanResult); + } + + const items = (scanResult.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + sourceId: src.id, + ...item, + gitIdentityId: src.gitIdentityId, + installed: installed + ? { isInstalled: true, scope: installed.scope, source: installed.source } + : { isInstalled: false }, + }; + }); + + return res.json({ ok: true, items }); + } catch (error) { + console.error('Failed to load catalog source:', error); + return res.status(500).json({ + ok: false, + error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' }, + }); + } + }); + + app.post('/api/config/skills/scan', async (req, res) => { + try { + const { source, subpath, gitIdentityId } = req.body || {}; + const identity = resolveGitIdentity(gitIdentityId); + + const result = await scanSkillsRepository({ + source, + subpath, + identity, + }); + + if (!result.ok) { + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + res.json({ ok: true, items: result.items }); + } catch (error) { + console.error('Failed to scan skills repository:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } }); + } + }); + + app.post('/api/config/skills/install', async (req, res) => { + try { + const { + source, + subpath, + gitIdentityId, + scope, + targetSource, + selections, + conflictPolicy, + conflictDecisions, + } = req.body || {}; + + let workingDirectory = null; + if (scope === 'project') { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ + ok: false, + error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' }, + }); + } + workingDirectory = resolved.directory; + } + + if (isClawdHubSource(source)) { + const result = await installSkillsFromClawdHub({ + scope, + targetSource, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + return res.status(400).json({ ok: false, error: result.error }); + } + + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + return res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + }); + } + + const identity = resolveGitIdentity(gitIdentityId); + + const result = await installSkillsFromRepository({ + source, + subpath, + identity, + scope, + targetSource, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + }); + } catch (error) { + console.error('Failed to install skills:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); + } + }); + + app.get('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + + res.json({ + name: skillName, + sources: sources, + scope: sources.md.scope, + source: sources.md.source, + exists: sources.md.exists + }); + } catch (error) { + console.error('Failed to get skill sources:', error); + res.status(500).json({ error: 'Failed to get skill configuration metadata' }); + } + }); + + app.get('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + const content = readSkillSupportingFile(sources.md.dir, filePath); + if (content === null) { + return res.status(404).json({ error: 'File not found' }); + } + + res.json({ path: filePath, content }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read skill file:', error); + res.status(500).json({ error: 'Failed to read skill file' }); + } + }); + + app.post('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { scope, source: skillSource, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating skill:', skillName); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createSkill(skillName, { ...config, source: skillSource }, directory, scope); + await refreshOpenCodeAfterConfigChange('skill creation'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create skill:', error); + res.status(500).json({ error: error.message || 'Failed to create skill' }); + } + }); + + app.patch('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating skill: ${skillName}`); + console.log('[Server] Working directory:', directory); + + updateSkill(skillName, updates, directory); + await refreshOpenCodeAfterConfigChange('skill update'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update skill:', error); + res.status(500).json({ error: error.message || 'Failed to update skill' }); + } + }); + + app.put('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { content } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + writeSkillSupportingFile(sources.md.dir, filePath, content || ''); + + res.json({ + success: true, + message: `File ${filePath} saved successfully`, + }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to write skill file:', error); + res.status(500).json({ error: error.message || 'Failed to write skill file' }); + } + }); + + app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + deleteSkillSupportingFile(sources.md.dir, filePath); + + res.json({ + success: true, + message: `File ${filePath} deleted successfully`, + }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to delete skill file:', error); + res.status(500).json({ error: error.message || 'Failed to delete skill file' }); + } + }); + + app.delete('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteSkill(skillName, directory); + await refreshOpenCodeAfterConfigChange('skill deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete skill:', error); + res.status(500).json({ error: error.message || 'Failed to delete skill' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/startup-pipeline-runtime.js b/packages/web/server/lib/opencode/startup-pipeline-runtime.js new file mode 100644 index 00000000..f7227e13 --- /dev/null +++ b/packages/web/server/lib/opencode/startup-pipeline-runtime.js @@ -0,0 +1,107 @@ +export const createStartupPipelineRuntime = (dependencies) => { + const { + createTerminalRuntime, + createServerStartupRuntime, + } = dependencies; + + const run = async (options) => { + const { + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + terminalHeartbeatIntervalMs, + terminalRebindWindowMs, + terminalMaxRebindsPerWindow, + setupProxy, + scheduleOpenCodeApiDetection, + bootstrapOpenCodeAtStartup, + staticRoutesRuntime, + process, + crypto, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + host, + port, + startupTunnelRequest, + onTunnelReady, + tunnelRuntimeContext, + attachSignals, + } = options; + + const terminalRuntime = createTerminalRuntime({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: terminalHeartbeatIntervalMs, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS: terminalRebindWindowMs, + TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow, + }); + + setupProxy(app); + scheduleOpenCodeApiDetection(); + void bootstrapOpenCodeAtStartup(); + + staticRoutesRuntime.registerStaticRoutes(app); + + const serverStartupRuntime = createServerStartupRuntime({ + process, + crypto, + server, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + }); + + const bindHost = serverStartupRuntime.resolveBindHost(host); + const startupResult = await serverStartupRuntime.startListeningAndMaybeTunnel({ + port, + bindHost, + startupTunnelRequest, + onTunnelReady, + }); + tunnelRuntimeContext.setActivePort(startupResult.activePort); + + serverStartupRuntime.attachProcessHandlers({ attachSignals }); + + return { + terminalRuntime, + }; + }; + + return { + run, + }; +}; diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js new file mode 100644 index 00000000..1f229239 --- /dev/null +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -0,0 +1,63 @@ +import { registerPwaManifestRoute } from './pwa-manifest-routes.js'; + +export const createStaticRoutesRuntime = (dependencies) => { + const { + fs, + path, + process, + __dirname, + express, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + } = dependencies; + + const resolveDistPath = () => { + const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : ''; + if (env) { + return path.resolve(env); + } + return path.join(__dirname, '..', 'dist'); + }; + + const registerStaticRoutes = (app) => { + const distPath = resolveDistPath(); + + if (fs.existsSync(distPath)) { + console.log(`Serving static files from ${distPath}`); + app.use(express.static(distPath, { + setHeaders(res, filePath) { + // Service workers should never be long-cached; iOS is especially sensitive. + if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) { + res.setHeader('Cache-Control', 'no-store'); + } + }, + })); + + registerPwaManifestRoute(app, { + process, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + }); + + app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + return; + } + + console.warn(`Warning: ${distPath} not found, static files will not be served`); + app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + res.status(404).send('Static files not found. Please build the application first.'); + }); + }; + + return { + registerStaticRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/theme-runtime.js b/packages/web/server/lib/opencode/theme-runtime.js new file mode 100644 index 00000000..df2639e3 --- /dev/null +++ b/packages/web/server/lib/opencode/theme-runtime.js @@ -0,0 +1,167 @@ +export const createThemeRuntime = (dependencies) => { + const { + fsPromises, + path, + themesDir, + maxThemeJsonBytes, + logger, + } = dependencies; + + const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; + const isValidThemeColor = (value) => isNonEmptyString(value); + + const normalizeThemeJson = (raw) => { + if (!raw || typeof raw !== 'object') { + return null; + } + + const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null; + const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null; + if (!metadata || !colors) { + return null; + } + + const id = metadata.id; + const name = metadata.name; + const variant = metadata.variant; + if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) { + return null; + } + + const primary = colors.primary; + const surface = colors.surface; + const interactive = colors.interactive; + const status = colors.status; + const syntax = colors.syntax; + const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null; + const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null; + + if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) { + return null; + } + + // Minimal fields required by CSSVariableGenerator and diff/syntax rendering. + const required = [ + primary.base, + primary.foreground, + surface.background, + surface.foreground, + surface.muted, + surface.mutedForeground, + surface.elevated, + surface.elevatedForeground, + surface.subtle, + interactive.border, + interactive.selection, + interactive.selectionForeground, + interactive.focusRing, + interactive.hover, + status.error, + status.errorForeground, + status.errorBackground, + status.errorBorder, + status.warning, + status.warningForeground, + status.warningBackground, + status.warningBorder, + status.success, + status.successForeground, + status.successBackground, + status.successBorder, + status.info, + status.infoForeground, + status.infoBackground, + status.infoBorder, + syntaxBase.background, + syntaxBase.foreground, + syntaxBase.keyword, + syntaxBase.string, + syntaxBase.number, + syntaxBase.function, + syntaxBase.variable, + syntaxBase.type, + syntaxBase.comment, + syntaxBase.operator, + syntaxHighlights.diffAdded, + syntaxHighlights.diffRemoved, + syntaxHighlights.lineNumber, + ]; + + if (!required.every(isValidThemeColor)) { + return null; + } + + const tags = Array.isArray(metadata.tags) + ? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0) + : []; + + return { + ...raw, + metadata: { + ...metadata, + id: id.trim(), + name: name.trim(), + description: typeof metadata.description === 'string' ? metadata.description : '', + version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0', + variant, + tags, + }, + }; + }; + + const readCustomThemesFromDisk = async () => { + try { + const entries = await fsPromises.readdir(themesDir, { withFileTypes: true }); + const themes = []; + const seen = new Set(); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith('.json')) continue; + + const filePath = path.join(themesDir, entry.name); + try { + const stat = await fsPromises.stat(filePath); + if (!stat.isFile()) continue; + if (stat.size > maxThemeJsonBytes) { + logger.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`); + continue; + } + + const rawText = await fsPromises.readFile(filePath, 'utf8'); + const parsed = JSON.parse(rawText); + const normalized = normalizeThemeJson(parsed); + if (!normalized) { + logger.warn(`[themes] Skip ${entry.name}: invalid theme JSON`); + continue; + } + + const id = normalized.metadata.id; + if (seen.has(id)) { + logger.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`); + continue; + } + + seen.add(id); + themes.push(normalized); + } catch (error) { + logger.warn(`[themes] Failed to read ${entry.name}:`, error); + } + } + + return themes; + } catch (error) { + // Missing dir is fine. + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return []; + } + logger.warn('[themes] Failed to list custom themes dir:', error); + return []; + } + }; + + return { + normalizeThemeJson, + readCustomThemesFromDisk, + }; +}; diff --git a/packages/web/server/lib/opencode/tunnel-wiring-runtime.js b/packages/web/server/lib/opencode/tunnel-wiring-runtime.js new file mode 100644 index 00000000..30f43161 --- /dev/null +++ b/packages/web/server/lib/opencode/tunnel-wiring-runtime.js @@ -0,0 +1,94 @@ +import { printTunnelWarning } from '../cloudflare-tunnel.js'; +import { createTunnelService } from '../tunnels/index.js'; +import { createTunnelRoutesRuntime } from '../tunnels/routes.js'; + +export const createTunnelWiringRuntime = (dependencies) => { + const { + crypto, + URL, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActiveTunnelController, + setActiveTunnelController, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + } = dependencies; + + const initialize = (app, initialPort) => { + let activePort = initialPort; + + const tunnelService = createTunnelService({ + registry: tunnelProviderRegistry, + getController: getActiveTunnelController, + setController: setActiveTunnelController, + getActivePort: () => activePort, + onQuickTunnelWarning: () => { + printTunnelWarning(); + }, + }); + + const tunnelRoutesRuntime = createTunnelRoutesRuntime({ + crypto, + URL, + tunnelService, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActivePort: () => activePort, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + getActiveTunnelController, + setActiveTunnelController, + }); + + tunnelRoutesRuntime.registerRoutes(app); + + return { + tunnelService, + startTunnelWithNormalizedRequest: (...args) => tunnelRoutesRuntime.startTunnelWithNormalizedRequest(...args), + getActivePort: () => activePort, + setActivePort: (value) => { + activePort = value; + }, + }; + }; + + return { + initialize, + }; +}; diff --git a/packages/web/server/lib/opencode/watcher.js b/packages/web/server/lib/opencode/watcher.js new file mode 100644 index 00000000..1523c4e9 --- /dev/null +++ b/packages/web/server/lib/opencode/watcher.js @@ -0,0 +1,107 @@ +export const createOpenCodeWatcherRuntime = (deps) => { + const { + waitForOpenCodePort, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + parseSseDataPayload, + onPayload, + } = deps; + + let abortController = null; + + const start = async () => { + if (abortController) { + return; + } + + await waitForOpenCodePort(); + + abortController = new AbortController(); + const signal = abortController.signal; + + let attempt = 0; + const run = async () => { + while (!signal.aborted) { + attempt += 1; + let upstream; + let reader; + try { + const url = buildOpenCodeUrl('/global/event', ''); + upstream = await fetch(url, { + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...getOpenCodeAuthHeaders(), + }, + signal, + }); + + if (!upstream.ok || !upstream.body) { + throw new Error(`bad status ${upstream.status}`); + } + + console.log('[PushWatcher] connected'); + + const decoder = new TextDecoder(); + reader = upstream.body.getReader(); + let buffer = ''; + + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); + + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex !== -1) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + separatorIndex = buffer.indexOf('\n\n'); + const payload = parseSseDataPayload(block); + onPayload(payload); + } + } + } catch (error) { + if (signal.aborted) { + return; + } + console.warn('[PushWatcher] disconnected', error?.message ?? error); + } finally { + try { + if (reader) { + await reader.cancel(); + reader.releaseLock(); + } else if (upstream?.body && !upstream.body.locked) { + await upstream.body.cancel(); + } + } catch { + } + } + + const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + }; + + void run(); + }; + + const stop = () => { + if (!abortController) { + return; + } + try { + abortController.abort(); + } catch { + } + abortController = null; + }; + + return { + start, + stop, + }; +}; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index c9dc10f9..228bfa8c 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -5,6 +5,7 @@ This module fetches quota and usage signals for supported providers in the web s ## Entrypoints and structure - `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints. - `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher. - `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference. - `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules. diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js new file mode 100644 index 00000000..9f3641ee --- /dev/null +++ b/packages/web/server/lib/quota/routes.js @@ -0,0 +1,27 @@ +export function registerQuotaRoutes(app, { getQuotaProviders }) { + app.get('/api/quota/providers', async (_req, res) => { + try { + const { listConfiguredQuotaProviders } = await getQuotaProviders(); + const providers = listConfiguredQuotaProviders(); + res.json({ providers }); + } catch (error) { + console.error('Failed to list quota providers:', error); + res.status(500).json({ error: error.message || 'Failed to list quota providers' }); + } + }); + + app.get('/api/quota/:providerId', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + const { fetchQuotaForProvider } = await getQuotaProviders(); + const result = await fetchQuotaForProvider(providerId); + res.json(result); + } catch (error) { + console.error('Failed to fetch quota:', error); + res.status(500).json({ error: error.message || 'Failed to fetch quota' }); + } + }); +} diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js new file mode 100644 index 00000000..6420c54a --- /dev/null +++ b/packages/web/server/lib/security/request-security.js @@ -0,0 +1,115 @@ +export const createRequestSecurityRuntime = (deps) => { + const { readSettingsFromDiskMigrated } = deps; + + const getUiSessionTokenFromRequest = (req) => { + const cookieHeader = req?.headers?.cookie; + if (!cookieHeader || typeof cookieHeader !== 'string') { + return null; + } + const segments = cookieHeader.split(';'); + for (const segment of segments) { + const [rawName, ...rest] = segment.split('='); + const name = rawName?.trim(); + if (!name) continue; + if (name !== 'oc_ui_session') continue; + const value = rest.join('=').trim(); + try { + return decodeURIComponent(value || ''); + } catch { + return value || null; + } + } + return null; + }; + + const rejectWebSocketUpgrade = (socket, statusCode, reason) => { + if (!socket || socket.destroyed) { + return; + } + + const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request'; + const body = Buffer.from(message, 'utf8'); + const statusText = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + }[statusCode] || 'Bad Request'; + + try { + socket.write( + `HTTP/1.1 ${statusCode} ${statusText}\r\n` + + 'Connection: close\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + `Content-Length: ${body.length}\r\n\r\n` + ); + socket.write(body); + } catch { + } + + try { + socket.destroy(); + } catch { + } + }; + + const getRequestOriginCandidates = async (req) => { + const origins = new Set(); + const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' + ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() + : ''; + const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); + + const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string' + ? req.headers['x-forwarded-host'].split(',')[0].trim() + : ''; + const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); + + if (host) { + origins.add(`${protocol}://${host}`); + const [hostname, port] = host.split(':'); + const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; + const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : ''; + if (normalizedHost === 'localhost') { + origins.add(`${protocol}://127.0.0.1${portSuffix}`); + origins.add(`${protocol}://[::1]${portSuffix}`); + } else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') { + origins.add(`${protocol}://localhost${portSuffix}`); + } + } + + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) { + origins.add(new URL(settings.publicOrigin.trim()).origin); + } + } catch { + } + + return origins; + }; + + const isRequestOriginAllowed = async (req) => { + const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; + if (!originHeader) { + return false; + } + + let normalizedOrigin = ''; + try { + normalizedOrigin = new URL(originHeader).origin; + } catch { + return false; + } + + const allowedOrigins = await getRequestOriginCandidates(req); + return allowedOrigins.has(normalizedOrigin); + }; + + return { + getUiSessionTokenFromRequest, + rejectWebSocketUpgrade, + isRequestOriginAllowed, + }; +}; diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 809f8361..2c477f7d 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -6,6 +6,7 @@ This module provides WebSocket protocol utilities for terminal input handling in ## Entrypoints and structure - `packages/web/server/lib/terminal/`: Terminal module directory. - `index.js`: Stable module entrypoint that re-exports protocol helpers/constants. + - `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration. - `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities. - `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js new file mode 100644 index 00000000..9453761a --- /dev/null +++ b/packages/web/server/lib/terminal/runtime.js @@ -0,0 +1,708 @@ +import { WebSocketServer } from 'ws'; +import { + TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + TERMINAL_INPUT_WS_PATH, + createTerminalInputWsControlFrame, + isRebindRateLimited, + normalizeTerminalInputWsMessageToText, + parseRequestPathname, + pruneRebindTimestamps, + readTerminalInputWsControlFrame, +} from './index.js'; + +export function createTerminalRuntime({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS, + TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW, +}) { + let ptyProviderPromise = null; + const getPtyProvider = async () => { + if (ptyProviderPromise) { + return ptyProviderPromise; + } + + ptyProviderPromise = (async () => { + const isBunRuntime = typeof globalThis.Bun !== 'undefined'; + + if (isBunRuntime) { + try { + const bunPty = await import('bun-pty'); + console.log('Using bun-pty for terminal sessions'); + return { spawn: bunPty.spawn, backend: 'bun-pty' }; + } catch (error) { + console.warn('bun-pty unavailable, falling back to node-pty'); + } + } + + try { + const nodePty = await import('node-pty'); + console.log('Using node-pty for terminal sessions'); + return { spawn: nodePty.spawn, backend: 'node-pty' }; + } catch (error) { + console.error('Failed to load node-pty:', error && error.message ? error.message : error); + if (isBunRuntime) { + throw new Error('No PTY backend available. Install bun-pty or node-pty.'); + } + throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)'); + } + })(); + + return ptyProviderPromise; + }; + + const getTerminalShellCandidates = () => { + if (process.platform === 'win32') { + const windowsCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + process.env.ComSpec, + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + 'pwsh.exe', + 'powershell.exe', + 'cmd.exe', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of windowsCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('\\') || candidate.includes('/') + ? candidate + : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + return resolved; + } + + const unixCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + '/bin/zsh', + '/bin/bash', + '/bin/sh', + 'zsh', + 'bash', + 'sh', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of unixCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + + return resolved; + }; + + const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => { + const shellCandidates = getTerminalShellCandidates(); + if (shellCandidates.length === 0) { + throw new Error('No executable shell found for terminal session'); + } + + let lastError = null; + for (const shell of shellCandidates) { + try { + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd, + env: { + ...env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); + + return { ptyProcess, shell }; + } catch (error) { + lastError = error; + console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error); + } + } + + const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed'; + throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`); + }; + + const terminalSessions = new Map(); + const MAX_TERMINAL_SESSIONS = 20; + const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; + const sanitizeTerminalEnv = (env) => { + const next = { ...env }; + delete next.BASH_XTRACEFD; + delete next.BASH_ENV; + delete next.ENV; + return next; + }; + const terminalInputCapabilities = { + input: { + preferred: 'ws', + transports: ['http', 'ws'], + ws: { + path: TERMINAL_INPUT_WS_PATH, + v: 1, + enc: 'text+json-bin-control', + }, + }, + }; + + const sendTerminalInputWsControl = (socket, payload) => { + if (!socket || socket.readyState !== 1) { + return; + } + + try { + socket.send(createTerminalInputWsControlFrame(payload), { binary: true }); + } catch { + } + }; + + let terminalInputWsServer = new WebSocketServer({ + noServer: true, + maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + }); + + terminalInputWsServer.on('connection', (socket) => { + const connectionState = { + boundSessionId: null, + invalidFrames: 0, + rebindTimestamps: [], + lastActivityAt: Date.now(), + }; + + sendTerminalInputWsControl(socket, { t: 'ok', v: 1 }); + + const heartbeatInterval = setInterval(() => { + if (socket.readyState !== 1) { + return; + } + + try { + socket.ping(); + } catch { + } + }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS); + + socket.on('pong', () => { + connectionState.lastActivityAt = Date.now(); + }); + + socket.on('message', (message, isBinary) => { + connectionState.lastActivityAt = Date.now(); + + if (isBinary) { + const controlMessage = readTerminalInputWsControlFrame(message); + if (!controlMessage || typeof controlMessage.t !== 'string') { + connectionState.invalidFrames += 1; + sendTerminalInputWsControl(socket, { + t: 'e', + c: 'BAD_FRAME', + f: connectionState.invalidFrames >= 10, + }); + if (connectionState.invalidFrames >= 10) { + socket.close(1008, 'protocol violation'); + } + return; + } + + if (controlMessage.t === 'p') { + sendTerminalInputWsControl(socket, { t: 'po', v: 1 }); + return; + } + + if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') { + connectionState.invalidFrames += 1; + sendTerminalInputWsControl(socket, { + t: 'e', + c: 'BAD_FRAME', + f: connectionState.invalidFrames >= 10, + }); + if (connectionState.invalidFrames >= 10) { + socket.close(1008, 'protocol violation'); + } + return; + } + + const now = Date.now(); + connectionState.rebindTimestamps = pruneRebindTimestamps( + connectionState.rebindTimestamps, + now, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS + ); + + if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) { + sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false }); + return; + } + + const nextSessionId = controlMessage.s.trim(); + const targetSession = terminalSessions.get(nextSessionId); + if (!targetSession) { + connectionState.boundSessionId = null; + sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); + return; + } + + connectionState.rebindTimestamps.push(now); + connectionState.boundSessionId = nextSessionId; + sendTerminalInputWsControl(socket, { t: 'bok', v: 1 }); + return; + } + + const payload = normalizeTerminalInputWsMessageToText(message); + if (payload.length === 0) { + return; + } + + if (!connectionState.boundSessionId) { + sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false }); + return; + } + + const session = terminalSessions.get(connectionState.boundSessionId); + if (!session) { + connectionState.boundSessionId = null; + sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); + return; + } + + try { + session.ptyProcess.write(payload); + session.lastActivity = Date.now(); + } catch { + sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false }); + } + }); + + socket.on('close', () => { + clearInterval(heartbeatInterval); + }); + + socket.on('error', (error) => { + void error; + }); + }); + + server.on('upgrade', (req, socket, head) => { + const pathname = parseRequestPathname(req.url); + if (pathname !== TERMINAL_INPUT_WS_PATH) { + return; + } + + const handleUpgrade = async () => { + try { + if (uiAuthController?.enabled) { + // Must be awaited: this call performs async token verification. + const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null); + if (!sessionToken) { + rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + return; + } + + const originAllowed = await isRequestOriginAllowed(req); + if (!originAllowed) { + rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + return; + } + } + + if (!terminalInputWsServer) { + rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); + return; + } + + terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => { + terminalInputWsServer.emit('connection', ws, req); + }); + } catch { + rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); + } + }; + + void handleUpgrade(); + }); + + const idleSweepInterval = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of terminalSessions.entries()) { + if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) { + console.log(`Cleaning up idle terminal session: ${sessionId}`); + try { + session.ptyProcess.kill(); + } catch (error) { + + } + terminalSessions.delete(sessionId); + } + } + }, 5 * 60 * 1000); + + app.post('/api/terminal/create', async (req, res) => { + try { + if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) { + return res.status(429).json({ error: 'Maximum terminal sessions reached' }); + } + + const { cwd, cols, rows } = req.body; + if (!cwd) { + return res.status(400).json({ error: 'cwd is required' }); + } + + try { + await fs.promises.access(cwd); + } catch { + return res.status(400).json({ error: 'Invalid working directory' }); + } + + const sessionId = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const envPath = buildAugmentedPath(); + const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); + + const pty = await getPtyProvider(); + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, + }); + + const session = { + ptyProcess, + ptyBackend: pty.backend, + cwd, + lastActivity: Date.now(), + clients: new Set(), + }; + + terminalSessions.set(sessionId, session); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`); + terminalSessions.delete(sessionId); + }); + + console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`); + res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); + } catch (error) { + console.error('Failed to create terminal session:', error); + res.status(500).json({ error: error.message || 'Failed to create terminal session' }); + } + }); + + app.get('/api/terminal/:sessionId/stream', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + const clientId = Math.random().toString(36).substring(7); + session.clients.add(clientId); + session.lastActivity = Date.now(); + + const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun'; + const ptyBackend = session.ptyBackend || 'unknown'; + res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`); + + const heartbeatInterval = setInterval(() => { + try { + + res.write(': heartbeat\n\n'); + } catch (error) { + console.error(`Heartbeat failed for client ${clientId}:`, error); + clearInterval(heartbeatInterval); + } + }, 15000); + + const dataHandler = (data) => { + try { + session.lastActivity = Date.now(); + const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`); + if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') { + session.ptyProcess.pause(); + res.once('drain', () => { + if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') { + session.ptyProcess.resume(); + } + }); + } + } catch (error) { + console.error(`Error sending data to client ${clientId}:`, error); + cleanup(); + } + }; + + const exitHandler = ({ exitCode, signal }) => { + try { + res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`); + res.end(); + } catch (error) { + + } + cleanup(); + }; + + const dataDisposable = session.ptyProcess.onData(dataHandler); + const exitDisposable = session.ptyProcess.onExit(exitHandler); + + const cleanup = () => { + clearInterval(heartbeatInterval); + session.clients.delete(clientId); + + if (dataDisposable && typeof dataDisposable.dispose === 'function') { + dataDisposable.dispose(); + } + if (exitDisposable && typeof exitDisposable.dispose === 'function') { + exitDisposable.dispose(); + } + + try { + res.end(); + } catch (error) { + + } + + console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`); + }; + + req.on('close', cleanup); + req.on('error', cleanup); + + console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`); + }); + + app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + const data = typeof req.body === 'string' ? req.body : ''; + + try { + session.ptyProcess.write(data); + session.lastActivity = Date.now(); + res.json({ success: true }); + } catch (error) { + console.error('Failed to write to terminal:', error); + res.status(500).json({ error: error.message || 'Failed to write to terminal' }); + } + }); + + app.post('/api/terminal/:sessionId/resize', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + const { cols, rows } = req.body; + if (!cols || !rows) { + return res.status(400).json({ error: 'cols and rows are required' }); + } + + try { + session.ptyProcess.resize(cols, rows); + session.lastActivity = Date.now(); + res.json({ success: true, cols, rows }); + } catch (error) { + console.error('Failed to resize terminal:', error); + res.status(500).json({ error: error.message || 'Failed to resize terminal' }); + } + }); + + app.delete('/api/terminal/:sessionId', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + try { + session.ptyProcess.kill(); + terminalSessions.delete(sessionId); + console.log(`Closed terminal session: ${sessionId}`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to close terminal:', error); + res.status(500).json({ error: error.message || 'Failed to close terminal' }); + } + }); + + app.post('/api/terminal/:sessionId/restart', async (req, res) => { + const { sessionId } = req.params; + const { cwd, cols, rows } = req.body; + + if (!cwd) { + return res.status(400).json({ error: 'cwd is required' }); + } + + const existingSession = terminalSessions.get(sessionId); + if (existingSession) { + try { + existingSession.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + } + + try { + try { + const stats = await fs.promises.stat(cwd); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Invalid working directory: not a directory' }); + } + } catch (error) { + return res.status(400).json({ error: 'Invalid working directory: not accessible' }); + } + + const newSessionId = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const envPath = buildAugmentedPath(); + const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); + + const pty = await getPtyProvider(); + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, + }); + + const session = { + ptyProcess, + ptyBackend: pty.backend, + cwd, + lastActivity: Date.now(), + clients: new Set(), + }; + + terminalSessions.set(newSessionId, session); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`); + terminalSessions.delete(newSessionId); + }); + + console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`); + res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); + } catch (error) { + console.error('Failed to restart terminal session:', error); + res.status(500).json({ error: error.message || 'Failed to restart terminal session' }); + } + }); + + app.post('/api/terminal/force-kill', (req, res) => { + const { sessionId, cwd } = req.body; + let killedCount = 0; + + if (sessionId) { + const session = terminalSessions.get(sessionId); + if (session) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + killedCount++; + } + } else if (cwd) { + for (const [id, session] of terminalSessions) { + if (session.cwd === cwd) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + } else { + for (const [id, session] of terminalSessions) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + + console.log(`Force killed ${killedCount} terminal session(s)`); + res.json({ success: true, killedCount }); + }); + + const shutdown = async () => { + if (idleSweepInterval) { + clearInterval(idleSweepInterval); + } + + for (const [sessionId, session] of terminalSessions.entries()) { + try { + session.ptyProcess.kill(); + } catch { + } + terminalSessions.delete(sessionId); + } + + if (!terminalInputWsServer) { + return; + } + + try { + for (const client of terminalInputWsServer.clients) { + try { + client.terminate(); + } catch { + } + } + + await new Promise((resolve) => { + terminalInputWsServer.close(() => resolve()); + }); + } catch { + } finally { + terminalInputWsServer = null; + } + }; + + return { shutdown }; +} diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index 73d5ab84..e80dc746 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -5,6 +5,8 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, ## Entrypoints and structure - `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*` and `/api/tts/*` endpoints. +- `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability. - `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration. - `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API. @@ -19,6 +21,9 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, - `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API. - `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content. +### Capability runtime (capability-runtime.js) +- `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`. + ## Constants ### Voice identifiers diff --git a/packages/web/server/lib/tts/capability-runtime.js b/packages/web/server/lib/tts/capability-runtime.js new file mode 100644 index 00000000..7dc1dc3a --- /dev/null +++ b/packages/web/server/lib/tts/capability-runtime.js @@ -0,0 +1,31 @@ +export const detectSayTtsCapability = async (processLike) => { + let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' }; + + if (processLike.platform === 'darwin') { + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const { stdout } = await execAsync('say -v "?"'); + const voices = stdout.split('\n') + .filter((line) => line.trim()) + .map((line) => { + const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/); + if (match) { + return { name: match[1].trim(), locale: match[2] }; + } + return null; + }) + .filter(Boolean); + sayTTSCapability = { available: true, voices }; + console.log(`macOS Say TTS available with ${voices.length} voices`); + } catch (error) { + sayTTSCapability = { available: false, voices: [], reason: 'say command not available' }; + console.log('macOS Say TTS not available:', error.message); + } + } else { + sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; + } + + return sayTTSCapability; +}; diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js new file mode 100644 index 00000000..170c80ac --- /dev/null +++ b/packages/web/server/lib/tts/routes.js @@ -0,0 +1,225 @@ +export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { + let ttsModulePromise = null; + const getTtsModule = async () => { + if (!ttsModulePromise) { + ttsModulePromise = import('./index.js'); + } + return ttsModulePromise; + }; + + app.post('/api/voice/token', async (req, res) => { + console.log('[Voice] Token request received:', { + contentType: req.headers['content-type'] || null, + }); + try { + const openaiApiKey = process.env.OPENAI_API_KEY; + console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); + + if (!openaiApiKey) { + return res.status(503).json({ + allowed: false, + error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.' + }); + } + + // Return success - OpenAI TTS is available + res.json({ + allowed: true, + provider: 'openai', + message: 'OpenAI TTS is available' + }); + } catch (error) { + console.error('[Voice] Token generation error:', error); + res.status(500).json({ + allowed: false, + error: 'Voice service error' + }); + } + }); + + // Server-side TTS endpoint - streams audio from OpenAI TTS API + app.post('/api/tts/speak', async (req, res) => { + try { + const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {}; + + console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey }); + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + // Dynamically import the TTS service (ESM) + const { ttsService } = await getTtsModule(); + + // Check availability - either server-configured or client-provided API key + const hasServerKey = ttsService.isAvailable(); + const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; + + if (!hasServerKey && !hasClientKey) { + return res.status(503).json({ + error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.' + }); + } + + let textToSpeak = text.trim(); + + // Optionally summarize long text before speaking using zen API + if (summarize && textToSpeak.length > threshold) { + try { + const { summarizeText } = await getTtsModule(); + const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); + + if (result.summarized && result.summary) { + textToSpeak = result.summary; + } + } catch (summarizeError) { + console.error('[TTS/speak] Summarization failed:', summarizeError); + // Continue with original text if summarization fails + } + } + + const result = await ttsService.generateSpeechStream({ + text: textToSpeak, + voice, + model, + speed, + instructions, + apiKey: hasClientKey ? apiKey.trim() : undefined + }); + + // Set headers for audio streaming + // Note: Don't set Transfer-Encoding manually - Express handles it automatically + res.setHeader('Content-Type', result.contentType); + res.setHeader('Cache-Control', 'no-cache'); + + // Collect the full audio buffer and send it + // This avoids chunked encoding issues with proxies + const reader = result.stream.getReader(); + const chunks = []; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(Buffer.from(value)); + } + const audioBuffer = Buffer.concat(chunks); + res.setHeader('Content-Length', audioBuffer.length); + res.send(audioBuffer); + } catch (streamError) { + console.error('[TTS] Stream error:', streamError); + if (!res.headersSent) { + res.status(500).json({ error: 'Stream error' }); + } else { + res.end(); + } + } + } catch (error) { + console.error('[TTS] Error:', error); + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : 'TTS generation failed' + }); + } + } + }); + + app.post('/api/tts/summarize', async (req, res) => { + try { + const { summarizeText } = await getTtsModule(); + const { text, threshold = 200, maxLength = 500 } = req.body || {}; + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); + + return res.json(result); + } catch (error) { + console.error('[Summarize] Error:', error); + const { sanitizeForTTS } = await getTtsModule(); + const sanitized = sanitizeForTTS(req.body?.text || ''); + return res.json({ summary: sanitized, summarized: false, reason: error.message }); + } + }); + + + // TTS status endpoint + app.get('/api/tts/status', async (_req, res) => { + try { + const { ttsService } = await getTtsModule(); + res.json({ + available: ttsService.isAvailable(), + voices: [ + 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', + 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar' + ] + }); + } catch (error) { + res.status(500).json({ error: 'Failed to check TTS status' }); + } + }); + + // macOS 'say' command TTS status endpoint - returns cached capability from startup + app.get('/api/tts/say/status', (_req, res) => { + res.json(sayTTSCapability); + }); + + // macOS 'say' command TTS speak endpoint + app.post('/api/tts/say/speak', async (req, res) => { + try { + const { text, voice = 'Samantha', rate = 200 } = req.body || {}; + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + // Check if we're on macOS + if (process.platform !== 'darwin') { + return res.status(503).json({ error: 'macOS say command not available on this platform' }); + } + + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const fs = await import('fs'); + const os = await import('os'); + const path = await import('path'); + const execAsync = promisify(exec); + + // Create temp file for audio output (use m4a for browser compatibility) + const tempDir = os.tmpdir(); + const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`); + + // Escape text for shell - escape both single quotes and double quotes + const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"'); + + // Generate audio file using 'say' command + // -o outputs to file, -r sets rate (words per minute) + // --data-format=aac outputs as m4a which browsers can decode + const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`; + console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate }); + + await execAsync(cmd); + + // Read the generated audio file + const audioBuffer = await fs.promises.readFile(tempFile); + + // Clean up temp file + fs.promises.unlink(tempFile).catch(() => {}); + + // Send audio response + res.setHeader('Content-Type', 'audio/mp4'); + res.setHeader('Content-Length', audioBuffer.length); + res.send(audioBuffer); + + } catch (error) { + console.error('[TTS-Say] Error:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Say command failed' + }); + } + }); +} diff --git a/packages/web/server/lib/tunnels/DOCUMENTATION.md b/packages/web/server/lib/tunnels/DOCUMENTATION.md new file mode 100644 index 00000000..53f859dd --- /dev/null +++ b/packages/web/server/lib/tunnels/DOCUMENTATION.md @@ -0,0 +1,18 @@ +# Tunnels Module Documentation + +## Purpose +This module contains tunnel provider orchestration for OpenChamber, including provider registry/service wiring, managed remote token config lifecycle, and tunnel HTTP route registration. + +## Entrypoints and structure +- `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration. +- `packages/web/server/lib/tunnels/registry.js`: provider registry. +- `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime. +- `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime. +- `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers. +- `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation. + +## Public exports (routes.js) +- `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers. +- Returned API: + - `registerRoutes(app)` + - `startTunnelWithNormalizedRequest(request)` diff --git a/packages/web/server/lib/tunnels/managed-config.js b/packages/web/server/lib/tunnels/managed-config.js new file mode 100644 index 00000000..cb4c6da0 --- /dev/null +++ b/packages/web/server/lib/tunnels/managed-config.js @@ -0,0 +1,201 @@ +export const createManagedTunnelConfigRuntime = (deps) => { + const { + fsPromises, + path, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + constants, + } = deps; + + const { + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, + CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + } = constants; + + let persistManagedRemoteTunnelConfigLock = Promise.resolve(); + + const sanitizeManagedRemoteTunnelConfigEntries = (value) => { + if (!Array.isArray(value)) { + return []; + } + + const result = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + const hostname = normalizeManagedRemoteTunnelHostname(entry.hostname); + const token = typeof entry.token === 'string' ? entry.token.trim() : ''; + const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(); + + if (!id || !name || !hostname || !token) { + continue; + } + if (seenIds.has(id) || seenHostnames.has(hostname)) { + continue; + } + + seenIds.add(id); + seenHostnames.add(hostname); + result.push({ id, name, hostname, token, updatedAt }); + } + + return result; + }; + + const writeManagedRemoteTunnelConfigToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); + }; + + const migrateManagedRemoteTunnelConfigFromLegacyFile = async () => { + try { + const legacyRaw = await fsPromises.readFile(CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(legacyRaw); + const tunnels = sanitizeManagedRemoteTunnelConfigEntries(parsed?.tunnels); + const migrated = { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels, + }; + await writeManagedRemoteTunnelConfigToDisk(migrated); + return migrated; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + console.warn('Failed to migrate legacy named tunnel config file:', error); + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + }; + + const readManagedRemoteTunnelConfigFromDisk = async () => { + try { + const raw = await fsPromises.readFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(parsed.tunnels), + }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return migrateManagedRemoteTunnelConfigFromLegacyFile(); + } + console.warn('Failed to read managed remote tunnel config file:', error); + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + }; + + const updateManagedRemoteTunnelConfig = async (mutate) => { + persistManagedRemoteTunnelConfigLock = persistManagedRemoteTunnelConfigLock.then(async () => { + const current = await readManagedRemoteTunnelConfigFromDisk(); + const next = mutate({ + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(current.tunnels), + }); + + await writeManagedRemoteTunnelConfigToDisk({ + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(next?.tunnels), + }); + }); + + return persistManagedRemoteTunnelConfigLock; + }; + + const syncManagedRemoteTunnelConfigWithPresets = async (presets) => { + const sanitizedPresets = normalizeManagedRemoteTunnelPresets(presets) || []; + + await updateManagedRemoteTunnelConfig((current) => { + const byId = new Map(current.tunnels.map((entry) => [entry.id, entry])); + const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry])); + + const nextTunnels = []; + for (const preset of sanitizedPresets) { + const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null; + if (!existing) { + continue; + } + + nextTunnels.push({ + ...existing, + id: preset.id, + name: preset.name, + hostname: preset.hostname, + }); + } + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: nextTunnels, + }; + }); + }; + + const upsertManagedRemoteTunnelToken = async ({ id, name, hostname, token }) => { + if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') { + return; + } + const normalizedId = id.trim(); + const normalizedName = name.trim(); + const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); + const normalizedToken = token.trim(); + if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) { + return; + } + + await updateManagedRemoteTunnelConfig((current) => { + const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname); + withoutConflicts.push({ + id: normalizedId, + name: normalizedName, + hostname: normalizedHostname, + token: normalizedToken, + updatedAt: Date.now(), + }); + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: withoutConflicts, + }; + }); + }; + + const resolveManagedRemoteTunnelToken = async ({ presetId, hostname }) => { + const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : ''; + const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); + const config = await readManagedRemoteTunnelConfigFromDisk(); + + if (normalizedPresetId) { + const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId); + if (byId?.token) { + return byId.token; + } + } + + if (normalizedHostname) { + const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname); + if (byHostname?.token) { + return byHostname.token; + } + } + + return ''; + }; + + return { + readManagedRemoteTunnelConfigFromDisk, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + }; +}; diff --git a/packages/web/server/lib/tunnels/routes.js b/packages/web/server/lib/tunnels/routes.js new file mode 100644 index 00000000..fa97cece --- /dev/null +++ b/packages/web/server/lib/tunnels/routes.js @@ -0,0 +1,605 @@ +export const createTunnelRoutesRuntime = (dependencies) => { + const { + crypto, + URL, + tunnelService, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActivePort, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + getActiveTunnelController, + setActiveTunnelController, + } = dependencies; + + const resolveActiveNormalizedTunnelMode = () => { + const mode = tunnelService.resolveActiveMode(); + if (mode === TUNNEL_MODE_MANAGED_LOCAL) { + return TUNNEL_MODE_MANAGED_LOCAL; + } + if (mode === TUNNEL_MODE_MANAGED_REMOTE) { + return TUNNEL_MODE_MANAGED_REMOTE; + } + return TUNNEL_MODE_QUICK; + }; + + const resolveNormalizedTunnelHost = (publicUrl) => { + if (typeof publicUrl !== 'string' || publicUrl.trim().length === 0) { + return null; + } + try { + return new URL(publicUrl).hostname.toLowerCase(); + } catch { + return null; + } + }; + + const resolvePreferredTunnelProvider = async (reqBody = null) => { + if (typeof reqBody?.provider === 'string' && reqBody.provider.trim().length > 0) { + return normalizeTunnelProvider(reqBody.provider); + } + const activeProvider = tunnelService.resolveActiveProvider(); + if (activeProvider) { + return normalizeTunnelProvider(activeProvider); + } + const settings = await readSettingsFromDiskMigrated(); + return normalizeTunnelProvider(settings?.tunnelProvider); + }; + + const startTunnelWithNormalizedRequest = async ({ + provider, + mode, + intent, + hostname, + token, + configPath, + selectedPresetId, + selectedPresetName, + }) => { + if (provider === TUNNEL_PROVIDER_CLOUDFLARE && mode === TUNNEL_MODE_MANAGED_REMOTE) { + setRuntimeManagedRemoteTunnelHostname(hostname); + setRuntimeManagedRemoteTunnelToken(token); + + if (token && hostname) { + await upsertManagedRemoteTunnelToken({ + id: selectedPresetId || hostname, + name: selectedPresetName || hostname, + hostname, + token, + }); + } + } + + const result = await tunnelService.start({ + provider, + mode, + intent, + configPath, + token, + hostname, + }); + + console.log(`Tunnel active (${result.provider}): ${result.publicUrl}`); + return { + publicUrl: result.publicUrl, + mode: result.activeMode, + provider: result.provider, + providerMetadata: result.providerMetadata, + }; + }; + + const createGenericModeChecks = ({ modeKey, requiredFields, doctorRequest, startupReady }) => { + const checks = [ + { + id: 'startup_readiness', + label: 'Provider startup readiness', + status: startupReady ? 'pass' : 'fail', + detail: startupReady + ? 'Provider dependency checks passed.' + : 'Resolve provider checks before starting tunnels.', + }, + ]; + + for (const field of requiredFields) { + const value = doctorRequest?.[field]; + const present = typeof value === 'string' ? value.trim().length > 0 : Boolean(value); + checks.push({ + id: `requirement_${field}`, + label: `Required: ${field}`, + status: present ? 'pass' : 'fail', + detail: present + ? `${field} is configured.` + : `${field} is required for ${modeKey}.`, + }); + } + + const failures = checks.filter((entry) => entry.status === 'fail').length; + const warnings = checks.filter((entry) => entry.status === 'warn').length; + return { + mode: modeKey, + checks, + summary: { + ready: failures === 0, + failures, + warnings, + }, + ready: failures === 0, + blockers: checks + .filter((entry) => entry.status === 'fail' && entry.id !== 'startup_readiness') + .map((entry) => entry.detail || entry.label || entry.id), + }; + }; + + const runTunnelDoctor = async ({ providerId, modeFilter, doctorRequest }) => { + const provider = tunnelProviderRegistry.get(providerId); + if (!provider) { + throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`); + } + + const capabilities = provider.capabilities || {}; + const modeKeys = Array.isArray(capabilities.modes) + ? capabilities.modes.map((entry) => entry?.key).filter((key) => typeof key === 'string' && key.length > 0) + : []; + + if (modeFilter && !modeKeys.includes(modeFilter)) { + throw new TunnelServiceError('mode_unsupported', `Provider '${providerId}' does not support mode '${modeFilter}'`); + } + + if (typeof provider.diagnose === 'function') { + const diagnosed = await provider.diagnose({ + ...doctorRequest, + mode: modeFilter || doctorRequest?.mode, + }, { + capabilities, + }); + const providerChecks = Array.isArray(diagnosed?.providerChecks) ? diagnosed.providerChecks : []; + const allModes = Array.isArray(diagnosed?.modes) ? diagnosed.modes : []; + const modes = modeFilter ? allModes.filter((entry) => entry?.mode === modeFilter) : allModes; + return { + ok: true, + provider: providerId, + providerChecks, + modes, + }; + } + + const availability = await tunnelService.checkAvailability(providerId); + const dependencyAvailable = Boolean(availability?.available); + const providerChecks = [{ + id: 'dependency', + label: 'Provider dependency', + status: dependencyAvailable ? 'pass' : 'fail', + detail: dependencyAvailable + ? (availability?.version || 'available') + : (availability?.message || 'Required provider dependency is unavailable.'), + }]; + + const targetModes = (Array.isArray(capabilities.modes) ? capabilities.modes : []) + .filter((entry) => !modeFilter || entry?.key === modeFilter); + const modes = targetModes.map((entry) => createGenericModeChecks({ + modeKey: entry.key, + requiredFields: Array.isArray(entry?.requires) ? entry.requires : [], + doctorRequest, + startupReady: dependencyAvailable, + })); + + return { + ok: true, + provider: providerId, + providerChecks, + modes, + }; + }; + + const registerRoutes = (app) => { + app.get('/api/openchamber/tunnel/check', async (req, res) => { + try { + const requestedProvider = typeof req?.query?.provider === 'string' && req.query.provider.trim().length > 0 + ? normalizeTunnelProvider(req.query.provider) + : await resolvePreferredTunnelProvider(); + const result = await tunnelService.checkAvailability(requestedProvider); + res.json({ + available: result.available, + provider: requestedProvider, + version: result.version || null, + }); + } catch (error) { + console.warn('Tunnel dependency check failed:', error); + res.json({ available: false, provider: null, version: null }); + } + }); + + const handleTunnelDoctor = async (req, res) => { + try { + const params = req.query || {}; + const body = req.body || {}; + + const providerId = typeof params.provider === 'string' && params.provider.trim().length > 0 + ? normalizeTunnelProvider(params.provider) + : await resolvePreferredTunnelProvider(); + const modeFilter = typeof params.mode === 'string' && params.mode.trim().length > 0 + ? params.mode.trim().toLowerCase() + : null; + + const settings = await readSettingsFromDiskMigrated(); + const selectedPresetId = typeof params.managedRemoteTunnelPresetId === 'string' + ? params.managedRemoteTunnelPresetId.trim() + : ''; + const requestConfigPath = normalizeOptionalPath(params.configPath) + ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); + const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(params.managedRemoteTunnelHostname); + const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(params.tunnelHostname); + const requestHostname = normalizeManagedRemoteTunnelHostname(params.hostname); + const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; + + const requestManagedRemoteToken = typeof body.managedRemoteTunnelToken === 'string' + ? body.managedRemoteTunnelToken.trim() + : ''; + const requestTunnelToken = typeof body.tunnelToken === 'string' + ? body.tunnelToken.trim() + : ''; + const requestToken = typeof body.token === 'string' + ? body.token.trim() + : ''; + const requestTokenProvided = body.managedRemoteTunnelTokenProvided === true + || body.tunnelTokenProvided === true + || body.tokenProvided === true; + const requestHostnameProvided = body.managedRemoteTunnelHostnameProvided === true + || body.tunnelHostnameProvided === true + || body.hostnameProvided === true; + const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' + ? settings.managedRemoteTunnelToken.trim() + : ''; + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const serverHasSavedManagedRemoteProfile = managedRemoteTunnelConfig.tunnels.some((entry) => { + const savedHostname = normalizeManagedRemoteTunnelHostname(entry?.hostname); + const savedToken = typeof entry?.token === 'string' ? entry.token.trim() : ''; + return Boolean(savedHostname && savedToken); + }); + const cliHasSavedManagedRemoteProfile = params.hasSavedManagedRemoteProfile === '1'; + const hasSavedManagedRemoteProfile = serverHasSavedManagedRemoteProfile || cliHasSavedManagedRemoteProfile; + const configManagedRemoteToken = providerId === TUNNEL_PROVIDER_CLOUDFLARE + ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) + : ''; + const runtimeHostname = getRuntimeManagedRemoteTunnelHostname(); + const runtimeToken = getRuntimeManagedRemoteTunnelToken(); + const token = requestToken + || requestTunnelToken + || requestManagedRemoteToken + || ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '') + || configManagedRemoteToken + || storedManagedRemoteToken; + + const doctorRequest = { + mode: modeFilter, + hostname, + token, + tokenProvided: requestTokenProvided, + hostnameProvided: requestHostnameProvided, + configPath: requestConfigPath, + hasSavedManagedRemoteProfile, + }; + + const result = await runTunnelDoctor({ + providerId, + modeFilter, + doctorRequest, + }); + return res.json(result); + } catch (error) { + if (error instanceof TunnelServiceError) { + return res.status(400).json({ ok: false, error: error.message, code: error.code }); + } + console.warn('Tunnel doctor failed:', error); + return res.status(500).json({ ok: false, error: 'Failed to run tunnel doctor' }); + } + }; + app.post('/api/openchamber/tunnel/doctor', handleTunnelDoctor); + app.get('/api/openchamber/tunnel/doctor', handleTunnelDoctor); + + app.get('/api/openchamber/tunnel/providers', (_req, res) => { + const providers = tunnelProviderRegistry.listCapabilities(); + return res.json({ providers }); + }); + + app.get('/api/openchamber/tunnel/status', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + const normalizedMode = normalizeTunnelMode(settings?.tunnelMode); + const managedRemoteHostname = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const managedRemoteTunnelPresetSummaries = managedRemoteTunnelConfig.tunnels.map((entry) => ({ + id: entry.id, + name: entry.name, + hostname: entry.hostname, + })); + const hasStoredManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; + const hasManagedRemoteTunnelToken = getRuntimeManagedRemoteTunnelToken().length > 0 || managedRemoteTunnelConfig.tunnels.length > 0 || hasStoredManagedRemoteToken; + const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); + const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + const activeSessions = tunnelAuthController.listTunnelSessions(); + const activeProvider = tunnelService.resolveActiveProvider(); + const provider = activeProvider || normalizeTunnelProvider(settings?.tunnelProvider); + + const publicUrl = tunnelService.getPublicUrl(); + if (!publicUrl) { + return res.json({ + active: false, + url: null, + mode: normalizedMode, + provider, + providerMetadata: null, + hasManagedRemoteTunnelToken, + managedRemoteTunnelHostname: managedRemoteHostname || null, + managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, + managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), + hasBootstrapToken: false, + bootstrapExpiresAt: null, + policy: 'tunnel-gated', + activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null, + activeSessions, + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } + + const activeNormalizedMode = resolveActiveNormalizedTunnelMode(); + const activeTunnelId = tunnelAuthController.getActiveTunnelId(); + const activeTunnelHost = tunnelAuthController.getActiveTunnelHost(); + const resolvedTunnelHost = resolveNormalizedTunnelHost(publicUrl); + const activeTunnelMode = tunnelAuthController.getActiveTunnelMode(); + const needsActiveTunnelSync = !activeTunnelId + || !activeTunnelHost + || !resolvedTunnelHost + || activeTunnelHost !== resolvedTunnelHost + || activeTunnelMode !== activeNormalizedMode; + if (needsActiveTunnelSync) { + tunnelAuthController.setActiveTunnel({ + tunnelId: activeTunnelId || crypto.randomUUID(), + publicUrl, + mode: activeNormalizedMode, + }); + } + + const bootstrapStatus = tunnelAuthController.getBootstrapStatus(); + const providerMetadata = tunnelService.getProviderMetadata(); + + return res.json({ + active: true, + url: publicUrl, + mode: activeNormalizedMode, + provider, + providerMetadata, + hasManagedRemoteTunnelToken, + managedRemoteTunnelHostname: managedRemoteHostname || null, + managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, + managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), + hasBootstrapToken: bootstrapStatus.hasBootstrapToken, + bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt, + policy: 'tunnel-gated', + activeTunnelMode: activeNormalizedMode, + activeSessions: tunnelAuthController.listTunnelSessions(), + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } catch (error) { + return res.status(500).json({ error: 'Failed to get tunnel status' }); + } + }); + + app.put('/api/openchamber/tunnel/managed-remote-token', async (req, res) => { + try { + const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : ''; + const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : ''; + const managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(req?.body?.managedRemoteTunnelHostname); + const managedRemoteTunnelToken = typeof req?.body?.managedRemoteTunnelToken === 'string' ? req.body.managedRemoteTunnelToken.trim() : ''; + + if (!presetId || !presetName || !managedRemoteTunnelHostname || !managedRemoteTunnelToken) { + return res.status(400).json({ ok: false, error: 'presetId, presetName, managedRemoteTunnelHostname and managedRemoteTunnelToken are required' }); + } + + await upsertManagedRemoteTunnelToken({ + id: presetId, + name: presetName, + hostname: managedRemoteTunnelHostname, + token: managedRemoteTunnelToken, + }); + + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + return res.json({ ok: true, managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) }); + } catch (error) { + return res.status(500).json({ ok: false, error: 'Failed to save managed remote tunnel token' }); + } + }); + + app.post('/api/openchamber/tunnel/start', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof _req?.body?.provider === 'string' && _req.body.provider.trim().length > 0) { + const rawProvider = _req.body.provider.trim().toLowerCase(); + if (!tunnelProviderRegistry.get(rawProvider)) { + return res.status(422).json({ ok: false, error: `Unsupported tunnel provider: ${rawProvider}`, code: 'provider_unsupported' }); + } + } + const provider = normalizeTunnelProvider(_req?.body?.provider ?? settings?.tunnelProvider); + const modeInput = _req?.body?.mode ?? settings?.tunnelMode; + const intent = typeof _req?.body?.intent === 'string' ? _req.body.intent.trim().toLowerCase() : undefined; + const mode = typeof modeInput === 'string' + ? modeInput.trim().toLowerCase() + : normalizeTunnelMode(modeInput); + if (typeof _req?.body?.mode === 'string' && _req.body.mode.trim().length > 0 && !isSupportedTunnelMode(mode)) { + return res.status(422).json({ ok: false, error: `Unsupported tunnel mode: ${mode}`, code: 'mode_unsupported' }); + } + const selectedPresetId = typeof _req?.body?.managedRemoteTunnelPresetId === 'string' ? _req.body.managedRemoteTunnelPresetId.trim() : ''; + const selectedPresetName = typeof _req?.body?.managedRemoteTunnelPresetName === 'string' ? _req.body.managedRemoteTunnelPresetName.trim() : ''; + const requestConfigPath = normalizeOptionalPath(_req?.body?.configPath) + ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); + const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.managedRemoteTunnelHostname); + const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.tunnelHostname); + const requestHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.hostname); + const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; + const requestManagedRemoteToken = typeof _req?.body?.managedRemoteTunnelToken === 'string' ? _req.body.managedRemoteTunnelToken.trim() : ''; + const requestTunnelToken = typeof _req?.body?.tunnelToken === 'string' ? _req.body.tunnelToken.trim() : ''; + const requestToken = typeof _req?.body?.token === 'string' ? _req.body.token.trim() : ''; + const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' ? settings.managedRemoteTunnelToken.trim() : ''; + const configManagedRemoteToken = provider === TUNNEL_PROVIDER_CLOUDFLARE + ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) + : ''; + const runtimeHostname = getRuntimeManagedRemoteTunnelHostname(); + const runtimeToken = getRuntimeManagedRemoteTunnelToken(); + const token = requestToken + || requestTunnelToken + || requestManagedRemoteToken + || ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '') + || configManagedRemoteToken + || storedManagedRemoteToken; + const requestConnectTtlMs = typeof _req?.body?.connectTtlMs === 'number' && Number.isFinite(_req.body.connectTtlMs) + ? normalizeTunnelBootstrapTtlMs(_req.body.connectTtlMs) + : undefined; + const requestSessionTtlMs = typeof _req?.body?.sessionTtlMs === 'number' && Number.isFinite(_req.body.sessionTtlMs) + ? normalizeTunnelSessionTtlMs(_req.body.sessionTtlMs) + : undefined; + const bootstrapTtlMs = requestConnectTtlMs ?? (settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs)); + const sessionTtlMs = requestSessionTtlMs ?? normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + + const previousTunnelId = tunnelAuthController.getActiveTunnelId(); + const previousMode = tunnelAuthController.getActiveTunnelMode(); + const previousProvider = tunnelService.resolveActiveProvider(); + const previousUrl = tunnelService.getPublicUrl(); + + const { publicUrl, provider: activeProvider, providerMetadata } = await startTunnelWithNormalizedRequest({ + provider, + mode, + intent, + hostname, + token, + configPath: requestConfigPath, + selectedPresetId, + selectedPresetName, + }); + + const replacedTunnel = Boolean(previousTunnelId) && ( + previousMode !== mode + || previousProvider !== activeProvider + || previousUrl !== publicUrl + ); + let revokedBootstrapCount = 0; + let invalidatedSessionCount = 0; + if (replacedTunnel && previousTunnelId) { + const revoked = tunnelAuthController.revokeTunnelArtifacts(previousTunnelId); + revokedBootstrapCount = revoked.revokedBootstrapCount; + invalidatedSessionCount = revoked.invalidatedSessionCount; + } + + tunnelAuthController.setActiveTunnel({ + tunnelId: replacedTunnel || !previousTunnelId ? crypto.randomUUID() : previousTunnelId, + publicUrl, + mode, + }); + + const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); + const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const isCloudflareProvider = activeProvider === TUNNEL_PROVIDER_CLOUDFLARE; + + return res.json({ + ok: true, + url: publicUrl, + mode, + provider: activeProvider, + providerMetadata, + managedRemoteTunnelHostname: isCloudflareProvider ? (hostname || null) : null, + managedRemoteTunnelTokenPresetIds: isCloudflareProvider ? managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) : [], + connectUrl, + bootstrapExpiresAt: bootstrapToken.expiresAt, + replacedTunnel, + replaced: replacedTunnel + ? { + mode: previousMode, + provider: previousProvider, + url: previousUrl, + } + : null, + revokedBootstrapCount, + invalidatedSessionCount, + policy: 'tunnel-gated', + activeTunnelMode: mode, + activeSessions: tunnelAuthController.listTunnelSessions(), + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } catch (error) { + console.error('Failed to start tunnel:', error); + setActiveTunnelController(null); + tunnelAuthController.clearActiveTunnel(); + if (error instanceof TunnelServiceError) { + const status = error.code === 'missing_dependency' + ? 400 + : (error.code === 'validation_error' || error.code === 'provider_unsupported' || error.code === 'mode_unsupported' + ? 422 + : 500); + return res.status(status).json({ ok: false, error: error.message, code: error.code }); + } + return res.status(500).json({ ok: false, error: 'Failed to start tunnel', code: 'startup_failed' }); + } + }); + + app.post('/api/openchamber/tunnel/stop', (_req, res) => { + let revokedBootstrapCount = 0; + let invalidatedSessionCount = 0; + const activeTunnelId = tunnelAuthController.getActiveTunnelId(); + + if (activeTunnelId) { + const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId); + revokedBootstrapCount = revoked.revokedBootstrapCount; + invalidatedSessionCount = revoked.invalidatedSessionCount; + } + + if (getActiveTunnelController()) { + console.log('Stopping active tunnel (user requested)...'); + tunnelService.stop(); + } + + tunnelAuthController.clearActiveTunnel(); + res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount }); + }); + }; + + return { + registerRoutes, + startTunnelWithNormalizedRequest, + }; +}; diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index b079db5a..95e8332c 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,6 +8,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; +const webRoot = path.join(repoRoot, 'packages/web'); function run(label, command, args, env = {}, options = {}) { return spawn(command, args, { @@ -81,18 +83,32 @@ async function stopChildTree(child) { const uiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180'; const backendPort = process.env.OPENCHAMBER_HMR_API_PORT || '3902'; +function clearViteCache() { + const cacheDirs = [ + path.join(webRoot, 'node_modules/.vite'), + path.join(webRoot, 'node_modules/.vite-temp'), + ]; + + for (const cacheDir of cacheDirs) { + if (!existsSync(cacheDir)) continue; + rmSync(cacheDir, { recursive: true, force: true }); + } +} + +clearViteCache(); + const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { OPENCHAMBER_PORT: backendPort, }); const vite = run( 'vite', 'bun', - ['x', 'vite', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'], + ['x', 'vite', '--force', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'], { OPENCHAMBER_PORT: backendPort, OPENCHAMBER_DISABLE_PWA_DEV: '1', }, - { cwd: path.join(repoRoot, 'packages/web') }, + { cwd: webRoot }, ); console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);