From 00821700defdd7ee779a25ae9c2abb62a5b84479 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 26 Jun 2026 19:27:53 +0300 Subject: [PATCH] chore: remove dead code (59 unused files + ~125 unused exports) (#1835) * chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin Co-authored-by: Bohdan Triapitsyn --- AGENTS.md | 2 +- knip.json | 62 + package.json | 1 + .../ui/src/apps/MobileProjectEditSurface.tsx | 2 +- packages/ui/src/apps/mobileAppContext.tsx | 9 - .../chat/AgentMentionAutocomplete.tsx | 256 ---- .../ui/src/components/chat/FileAttachment.tsx | 4 +- .../src/components/chat/MarkdownRenderer.tsx | 3 - .../src/components/chat/MobileAgentButton.tsx | 2 - .../src/components/chat/MobileModelButton.tsx | 2 - .../src/components/chat/PermissionRequest.tsx | 132 -- .../chat/PermissionToastActions.tsx | 139 -- .../ui/src/components/chat/StatusChip.tsx | 69 - .../src/components/chat/StreamingTextDiff.tsx | 0 .../components/chat/components/TurnList.tsx | 27 - .../src/components/chat/composerHighlight.ts | 4 +- .../chat/hooks/useChatTurnNavigation.ts | 6 +- .../chat/hooks/useStreamingTextThrottle.ts | 2 +- .../components/chat/hooks/useTurnLookup.ts | 26 - .../components/chat/lib/blockingRequests.ts | 61 - .../chat/lib/scroll/scrollIntent.ts | 78 - .../components/chat/lib/scroll/scrollSpy.ts | 4 +- .../components/chat/lib/turns/constants.ts | 4 - .../chat/lib/turns/historySignals.ts | 61 - .../chat/lib/turns/stabilizeTurnProjection.ts | 78 - .../components/chat/lib/turns/stageTurns.ts | 159 -- .../ui/src/components/chat/lib/turns/types.ts | 4 +- .../src/components/chat/markdown/decorate.ts | 6 +- .../components/chat/markdown/markdownCore.ts | 4 +- .../components/chat/markdown/markdownTheme.ts | 2 +- .../chat/message/TextSelectionMenu.tsx | 2 - .../src/components/chat/message/partUtils.ts | 12 +- .../chat/message/parts/MigratingPart.tsx | 31 - .../chat/message/parts/ReasoningPart.tsx | 5 +- .../message/parts/SessionActiveSpinner.tsx | 270 ---- .../chat/message/parts/generatedJsonResult.ts | 4 +- .../chat/message/parts/toolDiffUtils.ts | 2 +- .../chat/message/parts/toolRenderUtils.ts | 10 - .../components/chat/message/renderCompare.ts | 2 +- .../components/chat/message/toolRenderers.tsx | 213 +-- .../components/chat/mobileControlsUtils.ts | 59 +- .../chat/revertedMessageDockState.ts | 2 +- .../src/components/chat/useDraftStarters.ts | 2 +- .../desktop/DesktopHostSwitcher.tsx | 272 ---- packages/ui/src/components/diagram/index.ts | 2 +- .../ui/src/components/layout/RightSidebar.tsx | 2 +- packages/ui/src/components/layout/Sidebar.tsx | 2 +- .../ui/src/components/layout/VSCodeLayout.tsx | 3 +- .../model-picker/ModelPickerList.tsx | 4 +- .../components/multirun/BranchSelector.tsx | 6 - .../components/multirun/ModelMultiSelect.tsx | 10 +- packages/ui/src/components/multirun/index.ts | 3 - .../sections/SectionPlaceholder.tsx | 46 - .../components/sections/mcp/McpSidebar.tsx | 2 +- .../src/components/sections/mcp/mcpImport.ts | 2 +- .../src/components/sections/mcp/mcpOAuth.ts | 2 +- .../openchamber/OpenChamberVisualSettings.tsx | 2 +- .../sections/plugins/AddPluginDialog.tsx | 2 - .../src/components/sections/plugins/index.ts | 1 - .../RemoteInstancesSidebar.tsx | 240 --- .../sections/shared/SettingsSection.tsx | 62 - .../sections/shared/SettingsSidebarHeader.tsx | 63 - .../sections/shared/SettingsSidebarItem.tsx | 2 +- .../src/components/sections/shared/index.ts | 57 - .../skills/catalog/InstallFromRepoDialog.tsx | 597 -------- .../src/components/sections/skills/index.ts | 2 - .../sections/skills/skillLocations.ts | 7 - .../components/session/BranchPickerDialog.tsx | 575 ------- .../session/DirectoryAutocomplete.tsx | 314 ---- .../src/components/session/DirectoryTree.tsx | 1335 ----------------- .../session/sidebar/activitySections.ts | 6 +- .../sidebar/hooks/useArchivedAutoFolders.ts | 2 +- .../src/components/session/sidebar/utils.tsx | 14 - .../ui/src/components/ui/JsonTreeView.tsx | 1 - .../ui/src/components/ui/JsonTreeViewer.tsx | 1 - .../ui/src/components/ui/MemoryDebugPanel.tsx | 2 +- .../src/components/ui/MobileOverlayPanel.tsx | 2 - .../ui/src/components/ui/OpenCodeIcon.tsx | 35 - .../ui/src/components/ui/OpenCodeLogo.tsx | 41 - packages/ui/src/components/ui/TextLoop.tsx | 99 -- packages/ui/src/components/ui/alert.tsx | 66 - packages/ui/src/components/ui/button.tsx | 3 +- packages/ui/src/components/ui/card.tsx | 36 - packages/ui/src/components/ui/command.tsx | 40 - .../ui/src/components/ui/context-menu.tsx | 39 - packages/ui/src/components/ui/dialog.tsx | 12 - .../ui/src/components/ui/dropdown-menu.tsx | 59 - .../ui/src/components/ui/fancy-button.tsx | 134 -- packages/ui/src/components/ui/index.ts | 3 +- .../ui/src/components/ui/number-input.tsx | 2 +- packages/ui/src/components/ui/scroll-area.tsx | 56 - packages/ui/src/components/ui/select.tsx | 38 - packages/ui/src/components/ui/separator.tsx | 26 - packages/ui/src/components/ui/slider.tsx | 64 - packages/ui/src/components/ui/textarea.tsx | 27 +- packages/ui/src/components/ui/toggle.tsx | 47 - .../ui/src/components/ui/typewriter-text.tsx | 61 - packages/ui/src/components/views/DiffView.tsx | 21 +- .../components/views/agent-manager/index.ts | 2 - .../components/views/git/GitGraphSegment.tsx | 2 +- .../src/components/views/git/changesTree.ts | 2 +- .../ui/src/components/views/git/gitGraph.ts | 6 +- .../views/git/gitIndexMutationQueue.ts | 2 +- packages/ui/src/components/views/git/index.ts | 15 - packages/ui/src/components/views/index.ts | 2 - .../components/voice/VoiceStatusIndicator.tsx | 2 - packages/ui/src/components/voice/index.ts | 1 - packages/ui/src/constants/sidebar.ts | 82 - packages/ui/src/contexts/DrawerContext.tsx | 12 - packages/ui/src/hooks/useAssistantStatus.ts | 2 +- packages/ui/src/hooks/useAssistantTyping.ts | 229 --- packages/ui/src/hooks/useAvailableTools.ts | 49 - packages/ui/src/hooks/useChatAutoFollow.ts | 2 +- packages/ui/src/hooks/useDrawerSwipe.ts | 194 --- packages/ui/src/hooks/useEdgeSwipe.ts | 130 -- packages/ui/src/hooks/useGitmojiList.ts | 2 +- packages/ui/src/hooks/useLongPress.ts | 102 -- packages/ui/src/hooks/useProviderLogo.ts | 2 +- packages/ui/src/hooks/useRouter.ts | 99 -- packages/ui/src/hooks/useRuntimeAPIs.ts | 2 +- packages/ui/src/hooks/useSayTTS.ts | 2 +- packages/ui/src/hooks/useServerTTS.ts | 2 +- packages/ui/src/hooks/useSessionActivity.ts | 4 +- .../ui/src/hooks/useSessionAutoCleanup.ts | 2 +- packages/ui/src/hooks/useTimelineStaging.ts | 141 -- .../ui/src/hooks/useVisualViewport.test.ts | 35 - packages/ui/src/hooks/useVisualViewport.ts | 65 - packages/ui/src/lib/agentColors.ts | 4 - packages/ui/src/lib/api/types.ts | 67 +- packages/ui/src/lib/appearancePersistence.ts | 13 - packages/ui/src/lib/codeTheme.ts | 352 ----- packages/ui/src/lib/desktop.ts | 100 +- packages/ui/src/lib/desktopNative.ts | 43 - packages/ui/src/lib/desktopSsh.ts | 10 +- packages/ui/src/lib/device.ts | 14 +- packages/ui/src/lib/diff/patchFileDiff.ts | 17 - packages/ui/src/lib/directoryShowHidden.ts | 4 +- packages/ui/src/lib/draftStarters.ts | 2 - packages/ui/src/lib/execCommands.ts | 58 - packages/ui/src/lib/fileTypeIcons.ts | 2 - .../ui/src/lib/filesViewShowGitignored.ts | 4 +- .../ui/src/lib/git/branchNameGenerator.ts | 35 - packages/ui/src/lib/i18n/index.ts | 2 +- packages/ui/src/lib/i18n/intl.ts | 2 +- packages/ui/src/lib/i18n/runtime.ts | 2 +- packages/ui/src/lib/jsonTreeUtils.ts | 48 +- packages/ui/src/lib/magicPrompts.ts | 9 +- packages/ui/src/lib/messageCompletion.ts | 92 +- .../ui/src/lib/messageCursorPersistence.ts | 176 --- packages/ui/src/lib/messages/agentMentions.ts | 4 +- packages/ui/src/lib/messages/executionMeta.ts | 11 +- .../ui/src/lib/messages/inlineComments.ts | 17 +- .../ui/src/lib/messages/inlineMessageLinks.ts | 4 +- packages/ui/src/lib/mobileKeyboardMode.ts | 8 +- packages/ui/src/lib/mobileLayoutPreference.ts | 2 +- packages/ui/src/lib/multirun/title.ts | 4 - packages/ui/src/lib/openCodeStatus.ts | 2 +- packages/ui/src/lib/openInApps.ts | 5 - packages/ui/src/lib/openchamberConfig.ts | 26 +- packages/ui/src/lib/openchamberEvents.ts | 2 +- packages/ui/src/lib/opencode/client.ts | 7 +- .../ui/src/lib/opencode/provider-tracker.ts | 10 +- packages/ui/src/lib/outsideFileGrants.ts | 2 +- packages/ui/src/lib/passkeys.ts | 2 +- packages/ui/src/lib/path-utils.ts | 2 +- .../lib/permissions/editPermissionDefaults.ts | 101 -- packages/ui/src/lib/projectActions.ts | 28 - packages/ui/src/lib/projectMeta.ts | 4 +- packages/ui/src/lib/projectResolution.ts | 2 +- packages/ui/src/lib/pwa.ts | 5 - packages/ui/src/lib/quota/index.ts | 7 +- packages/ui/src/lib/quota/model-families.ts | 19 +- packages/ui/src/lib/quota/providers/base.ts | 8 - packages/ui/src/lib/quota/providers/index.ts | 7 - packages/ui/src/lib/quota/utils.ts | 2 +- packages/ui/src/lib/responseStyle.ts | 2 +- packages/ui/src/lib/reviewFlow.ts | 3 - packages/ui/src/lib/router/index.ts | 7 +- packages/ui/src/lib/router/serializeRoute.ts | 6 +- packages/ui/src/lib/router/types.ts | 10 - packages/ui/src/lib/runtime-auth.ts | 4 +- packages/ui/src/lib/runtime-url.ts | 2 - packages/ui/src/lib/runtimeSurface.ts | 2 +- packages/ui/src/lib/search/fuzzySearch.ts | 27 - .../ui/src/lib/server-compatibility.test.ts | 53 - packages/ui/src/lib/server-compatibility.ts | 159 -- packages/ui/src/lib/settings/metadata.ts | 15 +- packages/ui/src/lib/settings/search.ts | 6 +- packages/ui/src/lib/shortcuts.ts | 33 +- packages/ui/src/lib/startupTrace.ts | 4 +- .../ui/src/lib/terminal/SerializeAddon.ts | 497 ------ packages/ui/src/lib/terminalApi.ts | 4 +- packages/ui/src/lib/terminalTheme.ts | 29 - packages/ui/src/lib/theme/themes/index.ts | 8 +- packages/ui/src/lib/theme/vscode/adapter.ts | 2 +- packages/ui/src/lib/timeFormat.ts | 15 - packages/ui/src/lib/toolHelpers.ts | 2 +- packages/ui/src/lib/toolStatus.ts | 83 - packages/ui/src/lib/typography.ts | 107 -- packages/ui/src/lib/utils.ts | 2 +- .../ui/src/lib/voice/audioStreamService.ts | 7 +- .../ui/src/lib/voice/browserVoiceService.ts | 11 +- .../ui/src/lib/voice/contextFormatters.ts | 2 +- packages/ui/src/lib/voice/index.ts | 23 +- .../ui/src/lib/voice/realtimeClientTools.ts | 116 -- packages/ui/src/lib/voice/summarize.ts | 111 -- packages/ui/src/lib/voice/voiceConfig.ts | 3 - packages/ui/src/lib/voice/voiceHooks.ts | 8 - packages/ui/src/lib/voice/voiceSession.ts | 20 +- packages/ui/src/lib/voice/wasmSttService.ts | 5 +- packages/ui/src/lib/worktreeSessionCreator.ts | 196 --- .../ui/src/lib/worktrees/worktreeCreate.ts | 2 +- .../ui/src/lib/worktrees/worktreeManager.ts | 4 +- .../src/lib/worktrees/worktreeStatus.test.ts | 4 - packages/ui/src/stores/fileStore.ts | 272 ---- packages/ui/src/stores/globalSessions.ts | 23 - packages/ui/src/stores/types/sessionTypes.ts | 253 +--- packages/ui/src/stores/useAgentGroupsStore.ts | 4 +- packages/ui/src/stores/useCommandsStore.ts | 33 - .../ui/src/stores/useGitHubPrStatusStore.ts | 9 - packages/ui/src/stores/useGitStore.ts | 34 - .../src/stores/useInlineCommentDraftStore.ts | 2 - packages/ui/src/stores/useMcpConfigStore.ts | 10 +- packages/ui/src/stores/useMcpStore.ts | 4 +- packages/ui/src/stores/usePluginsStore.ts | 4 +- .../ui/src/stores/useSessionDisplayStore.ts | 2 +- packages/ui/src/stores/useSkillsStore.ts | 4 +- packages/ui/src/stores/useSnippetsStore.ts | 2 +- packages/ui/src/stores/useUpdateStore.ts | 2 +- .../ui/src/stores/utils/messageProjectors.ts | 46 - packages/ui/src/stores/utils/messageUtils.ts | 286 ---- .../ui/src/stores/utils/permissionUtils.ts | 15 - packages/ui/src/stores/utils/streamDebug.ts | 13 +- .../ui/src/stores/utils/streamingUtils.ts | 55 - packages/ui/src/sync/binary.ts | 20 - packages/ui/src/sync/content-cache.ts | 17 - packages/ui/src/sync/debug.ts | 8 +- packages/ui/src/sync/event-pipeline.ts | 7 - packages/ui/src/sync/global-sync-store.ts | 9 - packages/ui/src/sync/index.ts | 158 -- packages/ui/src/sync/notification-store.ts | 7 - packages/ui/src/sync/optimistic.ts | 40 - packages/ui/src/sync/persist-cache.ts | 19 - packages/ui/src/sync/reconnect-recovery.ts | 2 +- .../ui/src/sync/session-prefetch-cache.ts | 41 - .../ui/src/sync/session-worktree-contract.ts | 15 +- packages/ui/src/sync/streaming.ts | 14 +- packages/ui/src/sync/submit.ts | 133 -- packages/ui/src/sync/sync-context.tsx | 212 +-- packages/ui/src/sync/sync-refs.ts | 23 +- packages/ui/src/sync/types.ts | 8 +- packages/ui/src/sync/voice-store.ts | 16 - packages/ui/src/types/index.ts | 40 - packages/ui/src/types/multirun.ts | 2 +- packages/ui/src/types/permission.ts | 16 +- packages/ui/src/types/question.ts | 24 - packages/ui/src/types/quota.ts | 2 +- packages/ui/src/types/theme.ts | 129 +- packages/ui/src/types/tool.ts | 53 - packages/ui/src/types/worktree.ts | 2 - packages/vscode/src/gitService.ts | 130 +- packages/vscode/src/githubAuth.ts | 271 ---- packages/vscode/src/githubIssues.ts | 280 ---- packages/vscode/src/githubPr.ts | 486 ------ packages/vscode/src/githubPulls.ts | 672 --------- packages/vscode/src/opencode-ready.ts | 2 +- packages/vscode/src/opencode.ts | 4 +- packages/vscode/src/opencodeAuth.ts | 7 - packages/vscode/src/opencodeConfig.ts | 31 +- packages/vscode/src/quotaProviders.ts | 28 +- packages/vscode/src/skillsCatalog.ts | 175 +-- packages/vscode/src/webviewHtml.ts | 2 +- packages/vscode/webview/api/bridge.ts | 5 - packages/vscode/webview/api/streamPerf.ts | 2 +- .../vscode/webview/requestBodyTransport.ts | 6 +- packages/web/bin/cli-output.js | 7 - packages/web/server/lib/cloudflare-tunnel.js | 4 +- .../web/server/lib/event-stream/global-hub.js | 2 +- packages/web/server/lib/event-stream/index.js | 12 - .../web/server/lib/event-stream/runtime.js | 2 +- .../web/server/lib/notifications/index.js | 5 +- packages/web/server/lib/opencode/agents.js | 6 - packages/web/server/lib/opencode/commands.js | 5 - .../lib/opencode/feature-routes-runtime.js | 89 +- packages/web/server/lib/opencode/index.js | 95 -- .../web/server/lib/opencode/npm-registry.js | 6 +- packages/web/server/lib/opencode/proxy.js | 6 +- packages/web/server/lib/opencode/shared.js | 6 - packages/web/server/lib/opencode/skills.js | 2 - packages/web/server/lib/opencode/snippets.js | 2 - packages/web/server/lib/package-manager.js | 10 +- .../web/server/lib/projects/project-config.js | 8 - .../web/server/lib/quota/DOCUMENTATION.md | 4 +- .../web/server/lib/quota/providers/claude.js | 2 +- .../web/server/lib/quota/providers/codex.js | 2 +- .../web/server/lib/quota/providers/copilot.js | 2 +- .../web/server/lib/quota/providers/cursor.js | 2 +- .../server/lib/quota/providers/google/auth.js | 4 +- .../lib/quota/providers/google/index.js | 27 - .../lib/quota/providers/google/transforms.js | 2 +- .../web/server/lib/quota/providers/index.js | 2 +- .../server/lib/quota/providers/interface.js | 55 - .../web/server/lib/quota/providers/kimi.js | 2 +- .../quota/providers/minimax-cn-coding-plan.js | 2 +- .../quota/providers/minimax-coding-plan.js | 2 +- .../web/server/lib/quota/providers/nanogpt.js | 2 +- .../lib/quota/providers/ollama-cloud.js | 2 +- .../web/server/lib/quota/providers/openai.js | 8 +- .../server/lib/quota/providers/openrouter.js | 2 +- .../web/server/lib/quota/providers/wafer.js | 2 +- .../web/server/lib/quota/providers/zai.js | 2 +- .../quota/providers/zhipuai-coding-plan.js | 2 +- .../web/server/lib/skills-catalog/cache.js | 4 - .../server/lib/skills-catalog/clawdhub/api.js | 32 - .../lib/skills-catalog/clawdhub/index.js | 30 - .../lib/skills-catalog/clawdhub/scan.js | 52 - .../lib/skills-catalog/curated-sources.js | 2 +- .../web/server/lib/skills-catalog/index.js | 42 - .../web/server/lib/skills-catalog/source.js | 5 + packages/web/server/lib/terminal/index.js | 31 - packages/web/server/lib/terminal/runtime.js | 20 +- packages/web/server/lib/text/summarization.js | 2 +- .../web/server/lib/tunnels/providers/ngrok.js | 2 +- packages/web/server/lib/tunnels/types.js | 4 +- 324 files changed, 444 insertions(+), 14876 deletions(-) create mode 100644 knip.json delete mode 100644 packages/ui/src/components/chat/AgentMentionAutocomplete.tsx delete mode 100644 packages/ui/src/components/chat/PermissionRequest.tsx delete mode 100644 packages/ui/src/components/chat/PermissionToastActions.tsx delete mode 100644 packages/ui/src/components/chat/StatusChip.tsx delete mode 100644 packages/ui/src/components/chat/StreamingTextDiff.tsx delete mode 100644 packages/ui/src/components/chat/components/TurnList.tsx delete mode 100644 packages/ui/src/components/chat/hooks/useTurnLookup.ts delete mode 100644 packages/ui/src/components/chat/lib/blockingRequests.ts delete mode 100644 packages/ui/src/components/chat/lib/scroll/scrollIntent.ts delete mode 100644 packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts delete mode 100644 packages/ui/src/components/chat/lib/turns/stageTurns.ts delete mode 100644 packages/ui/src/components/chat/message/parts/MigratingPart.tsx delete mode 100644 packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx delete mode 100644 packages/ui/src/components/sections/SectionPlaceholder.tsx delete mode 100644 packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx delete mode 100644 packages/ui/src/components/sections/shared/SettingsSection.tsx delete mode 100644 packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx delete mode 100644 packages/ui/src/components/sections/shared/index.ts delete mode 100644 packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx delete mode 100644 packages/ui/src/components/sections/skills/index.ts delete mode 100644 packages/ui/src/components/session/BranchPickerDialog.tsx delete mode 100644 packages/ui/src/components/session/DirectoryAutocomplete.tsx delete mode 100644 packages/ui/src/components/session/DirectoryTree.tsx delete mode 100644 packages/ui/src/components/ui/OpenCodeIcon.tsx delete mode 100644 packages/ui/src/components/ui/OpenCodeLogo.tsx delete mode 100644 packages/ui/src/components/ui/TextLoop.tsx delete mode 100644 packages/ui/src/components/ui/alert.tsx delete mode 100644 packages/ui/src/components/ui/fancy-button.tsx delete mode 100644 packages/ui/src/components/ui/scroll-area.tsx delete mode 100644 packages/ui/src/components/ui/separator.tsx delete mode 100644 packages/ui/src/components/ui/slider.tsx delete mode 100644 packages/ui/src/components/ui/toggle.tsx delete mode 100644 packages/ui/src/components/ui/typewriter-text.tsx delete mode 100644 packages/ui/src/components/views/git/index.ts delete mode 100644 packages/ui/src/components/views/index.ts delete mode 100644 packages/ui/src/hooks/useAssistantTyping.ts delete mode 100644 packages/ui/src/hooks/useAvailableTools.ts delete mode 100644 packages/ui/src/hooks/useDrawerSwipe.ts delete mode 100644 packages/ui/src/hooks/useEdgeSwipe.ts delete mode 100644 packages/ui/src/hooks/useLongPress.ts delete mode 100644 packages/ui/src/hooks/useTimelineStaging.ts delete mode 100644 packages/ui/src/hooks/useVisualViewport.test.ts delete mode 100644 packages/ui/src/hooks/useVisualViewport.ts delete mode 100644 packages/ui/src/lib/codeTheme.ts delete mode 100644 packages/ui/src/lib/execCommands.ts delete mode 100644 packages/ui/src/lib/messageCursorPersistence.ts delete mode 100644 packages/ui/src/lib/permissions/editPermissionDefaults.ts delete mode 100644 packages/ui/src/lib/quota/providers/base.ts delete mode 100644 packages/ui/src/lib/server-compatibility.test.ts delete mode 100644 packages/ui/src/lib/server-compatibility.ts delete mode 100644 packages/ui/src/lib/terminal/SerializeAddon.ts delete mode 100644 packages/ui/src/lib/toolStatus.ts delete mode 100644 packages/ui/src/lib/voice/realtimeClientTools.ts delete mode 100644 packages/ui/src/stores/fileStore.ts delete mode 100644 packages/ui/src/stores/utils/messageProjectors.ts delete mode 100644 packages/ui/src/stores/utils/messageUtils.ts delete mode 100644 packages/ui/src/stores/utils/streamingUtils.ts delete mode 100644 packages/ui/src/sync/index.ts delete mode 100644 packages/ui/src/sync/submit.ts delete mode 100644 packages/ui/src/types/tool.ts delete mode 100644 packages/vscode/src/githubAuth.ts delete mode 100644 packages/vscode/src/githubIssues.ts delete mode 100644 packages/vscode/src/githubPr.ts delete mode 100644 packages/vscode/src/githubPulls.ts delete mode 100644 packages/web/server/lib/opencode/index.js delete mode 100644 packages/web/server/lib/quota/providers/interface.js delete mode 100644 packages/web/server/lib/skills-catalog/clawdhub/index.js delete mode 100644 packages/web/server/lib/skills-catalog/index.js delete mode 100644 packages/web/server/lib/terminal/index.js diff --git a/AGENTS.md b/AGENTS.md index 1ec26a2e..263dd21d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -451,7 +451,7 @@ A single store with N properties means every subscriber re-evaluates on every st ## Validation expectations -- Run type-check/lint validation before finalizing source-code changes that can affect TypeScript, runtime behavior, builds, lint rules, package resolution, or generated assets, but keep validation scoped to the edited workspace by default. Prefer the package-level command for the package you changed (for example the relevant workspace's `type-check`/`lint`) instead of workspace-wide `bun run type-check` / `bun run lint`. Use workspace-wide checks only when the change spans multiple workspaces, shared package contracts, root tooling/config, dependency resolution, generated assets used across packages, or when a narrower command cannot cover the risk. Use a sufficiently long tool timeout for any broad checks (for example 240000ms) so successful package-level results are not lost to a tool timeout. For docs-only or isolated config-only changes, run the narrowest relevant validation instead (for example JSON/schema validation) and do not run full checks unless the change can affect code execution. +- Run type-check/lint validation before finalizing source-code changes that can affect TypeScript, runtime behavior, builds, lint rules, package resolution, or generated assets, and run `bun run dead-code` when the change can add, remove, rename, or reshape files, exports, types, workspace entrypoints, or module imports. Keep validation scoped to the edited workspace by default. Prefer the package-level command for the package you changed (for example the relevant workspace's `type-check`/`lint`) instead of workspace-wide `bun run type-check` / `bun run lint`. Use workspace-wide checks only when the change spans multiple workspaces, shared package contracts, root tooling/config, dependency resolution, generated assets used across packages, or when a narrower command cannot cover the risk. Use a sufficiently long tool timeout for any broad checks (for example 240000ms) so successful package-level results are not lost to a tool timeout. For docs-only or isolated config-only changes, run the narrowest relevant validation instead (for example JSON/schema validation) and do not run full checks unless the change can affect code execution. - 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. diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..beb1972a --- /dev/null +++ b/knip.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://unpkg.com/knip@latest/schema.json", + "workspaces": { + ".": { + "entry": [ + "scripts/**/*.{js,cjs,mjs,ts}" + ], + "project": [ + "*.{js,cjs,mjs,ts}", + "scripts/**/*.{js,cjs,mjs,ts}" + ] + }, + "packages/ui": { + "entry": [ + "src/**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx}", + "src/**/__tests__/**/*.{js,cjs,mjs,jsx,ts,tsx}" + ], + "project": [ + "src/**/*.{ts,tsx}" + ] + }, + "packages/web": { + "entry": [ + "src/mobile-main.tsx", + "src/mini-chat-main.tsx", + "src/sw.ts", + "server/**/*.{test,spec}.{js,cjs,mjs}", + "src/**/*.{test,spec}.{ts,tsx}" + ], + "project": [ + "bin/**/*.js", + "server/**/*.{js,mjs,cjs}", + "src/**/*.{ts,tsx}" + ] + }, + "packages/electron": { + "entry": [ + "main.mjs", + "preload.mjs", + "tray.mjs", + "ssh-manager.mjs", + "opencode-cwd.mjs", + "*.{test,spec}.{js,cjs,mjs}", + "scripts/**/*.{js,cjs,mjs}" + ], + "project": [ + "*.{js,cjs,mjs}", + "scripts/**/*.{js,cjs,mjs}" + ] + }, + "packages/vscode": { + "entry": [ + "src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}", + "webview/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}" + ], + "project": [ + "src/**/*.{ts,tsx}", + "webview/**/*.{ts,tsx}" + ] + } + } +} diff --git a/package.json b/package.json index d2d0aefd..197d82f7 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "vscode:package": "bun run --cwd packages/vscode package", "vscode:type-check": "bun run --cwd packages/vscode type-check", "docs:validate": "node scripts/docs/validate-docs.mjs", + "dead-code": "bunx knip --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,namespaceMembers,duplicates", "doctor": "node scripts/react-doctor.mjs", "icons:sprite": "node scripts/generate-file-type-sprite.mjs", "icons:generate": "bun run scripts/generate-icon-sprite.mjs", diff --git a/packages/ui/src/apps/MobileProjectEditSurface.tsx b/packages/ui/src/apps/MobileProjectEditSurface.tsx index 519822c2..ed1eaa3e 100644 --- a/packages/ui/src/apps/MobileProjectEditSurface.tsx +++ b/packages/ui/src/apps/MobileProjectEditSurface.tsx @@ -32,7 +32,7 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; import { MobileSurfaceShell } from './MobileSurfaceShell'; -export type MobileEditableProject = { +type MobileEditableProject = { id: string; label: string; path: string; diff --git a/packages/ui/src/apps/mobileAppContext.tsx b/packages/ui/src/apps/mobileAppContext.tsx index 6689ce74..3acbbef0 100644 --- a/packages/ui/src/apps/mobileAppContext.tsx +++ b/packages/ui/src/apps/mobileAppContext.tsx @@ -19,15 +19,6 @@ export const DedicatedMobileAppProvider: React.FC<{ {children} ); -/** - * Returns true when the surrounding tree is the dedicated MobileApp root - * (Capacitor or hosted /mobile.html), as opposed to the desktop responsive - * mobile path. Use this to suppress UI that exists only to bridge the - * desktop sidebar/layout into mobile, since the dedicated mobile root has - * its own native-feeling navigation and no sidebars to bridge into. - */ -export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null; - /** * Returns the dedicated mobile app's surface-opening actions, or null when * not inside the dedicated mobile root. Components living in shared chat / diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx deleted file mode 100644 index ac2394b8..00000000 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import React from 'react'; -import { cn, fuzzyMatch } from '@/lib/utils'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { useI18n } from '@/lib/i18n'; - -interface AgentInfo { - name: string; - description?: string; - mode?: string | null; - scope?: string; - isBuiltIn?: boolean; -} - -export interface AgentMentionAutocompleteHandle { - handleKeyDown: (key: string) => void; -} - -type AutocompleteTab = 'commands' | 'agents' | 'files'; - -const isMentionableAgentMode = (mode?: string | null): boolean => { - if (!mode) return false; - return mode !== 'primary'; -}; - -interface AgentMentionAutocompleteProps { - searchQuery: string; - onAgentSelect: (agentName: string) => void; - onClose: () => void; - showTabs?: boolean; - activeTab?: AutocompleteTab; - onTabSelect?: (tab: AutocompleteTab) => void; -} - -export const AgentMentionAutocomplete = React.forwardRef(({ - searchQuery, - onAgentSelect, - onClose, - showTabs, - activeTab = 'agents', - onTabSelect, -}, ref) => { - const { t } = useI18n(); - const containerRef = React.useRef(null); - const [selectedIndex, setSelectedIndex] = React.useState(0); - const selectedIndexRef = React.useRef(0); - const [agents, setAgents] = React.useState([]); - const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); - const ignoreTabClickRef = React.useRef(false); - const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); - const configAgentsCount = useConfigStore((state) => state.agents.length); - const agentsWithMetadata = useAgentsStore((state) => state.agents); - const loadAgents = useAgentsStore((state) => state.loadAgents); - - React.useEffect(() => { - if (agentsWithMetadata.length === 0 && configAgentsCount === 0) { - void loadAgents(); - } - }, [loadAgents, agentsWithMetadata.length, configAgentsCount]); - - React.useEffect(() => { - const visibleAgents = getVisibleAgents(); - const filtered = visibleAgents - .filter((agent) => isMentionableAgentMode(agent.mode)) - .map((agent) => { - const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined; - return { - name: agent.name, - description: agent.description, - mode: agent.mode ?? undefined, - scope: metadata?.scope, - isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false, - }; - }); - - const normalizedQuery = searchQuery.trim(); - const matches = normalizedQuery.length - ? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery)) - : filtered; - - matches.sort((a, b) => a.name.localeCompare(b.name)); - - setAgents(matches); - setSelectedIndex(0); - }, [getVisibleAgents, searchQuery, agentsWithMetadata]); - - React.useEffect(() => { - selectedIndexRef.current = selectedIndex; - }, [selectedIndex]); - - React.useEffect(() => { - itemRefs.current[selectedIndex]?.scrollIntoView({ - block: 'nearest', - }); - }, [selectedIndex]); - - React.useEffect(() => { - const handlePointerDown = (event: MouseEvent | TouchEvent) => { - const target = event.target as Node | null; - if (!target || !containerRef.current) { - return; - } - if (!containerRef.current.contains(target)) { - onClose(); - } - }; - - document.addEventListener('pointerdown', handlePointerDown, true); - return () => { - document.removeEventListener('pointerdown', handlePointerDown, true); - }; - }, [onClose]); - - React.useImperativeHandle(ref, () => ({ - handleKeyDown: (key: string) => { - if (key === 'Escape') { - onClose(); - return; - } - - if (!agents.length) { - return; - } - - if (key === 'ArrowDown') { - setSelectedIndex((prev) => (prev + 1) % agents.length); - return; - } - - if (key === 'ArrowUp') { - setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length); - return; - } - - if (key === 'Enter' || key === 'Tab') { - const safeIndex = ((selectedIndexRef.current % agents.length) + agents.length) % agents.length; - const agent = agents[safeIndex]; - if (agent) { - onAgentSelect(agent.name); - } - } - }, - }), [agents, onAgentSelect, onClose]); - - const renderAgent = (agent: AgentInfo, index: number) => { - const isSystem = agent.isBuiltIn; - const isProject = agent.scope === 'project'; - - return ( -
{ - itemRefs.current[index] = el; - }} - className={cn( - 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', - index === selectedIndex && 'bg-interactive-selection' - )} - onClick={() => onAgentSelect(agent.name)} - onMouseMove={() => setSelectedIndex(index)} - > -
-
- #{agent.name} - {isSystem ? ( - - {t('chat.agentMentionAutocomplete.badge.system')} - - ) : agent.scope ? ( - - {agent.scope} - - ) : null} -
- {agent.description && ( -
- {agent.description} -
- )} -
-
- ); - }; - - const tabs = React.useMemo(() => ([ - { id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') }, - { id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') }, - { id: 'files' as const, label: t('chat.autocomplete.tabs.files') }, - ]), [t]); - - return ( -
- {showTabs ? ( -
-
- {tabs.map((tab) => ( - - ))} -
-
- ) : null} - - {agents.length ? ( -
- {agents.map((agent, index) => renderAgent(agent, index))} -
- ) : ( -
- {t('chat.agentMentionAutocomplete.empty')} -
- )} -
-
- {t('chat.autocomplete.keyboardHint')} -
-
- ); -}); - -AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete'; diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index d0f8b4de..5949164f 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -15,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device'; import type { ToolPopupContent } from './message/types'; -export const FileAttachmentButton = memo(() => { +const FileAttachmentButton = memo(() => { const { t } = useI18n(); const fileInputRef = useRef(null); const addAttachedFile = useInputStore((state) => state.addAttachedFile); @@ -912,7 +912,7 @@ interface ImageGalleryProps { onShowPopup?: (content: ToolPopupContent) => void; } -export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => { +const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => { if (urls.length === 0) return null; const getGridCols = () => { diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 162e4e1d..4d56736b 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -6,9 +6,6 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // DOM morphing, plus beautiful-mermaid) is loaded on demand, keeping the // initial bundle lean. -export type { MarkdownVariant } from './MarkdownRendererImpl'; - - const MarkdownRendererLazy = lazyWithChunkRecovery(() => import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) ); diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index b39571dd..1d1db004 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -92,5 +92,3 @@ export const MobileAgentButton: React.FC = ({ onCycleAge ); }; - -export default MobileAgentButton; diff --git a/packages/ui/src/components/chat/MobileModelButton.tsx b/packages/ui/src/components/chat/MobileModelButton.tsx index 568ef820..b34d45ed 100644 --- a/packages/ui/src/components/chat/MobileModelButton.tsx +++ b/packages/ui/src/components/chat/MobileModelButton.tsx @@ -36,5 +36,3 @@ export const MobileModelButton: React.FC = ({ onOpenMode ); }; - -export default MobileModelButton; diff --git a/packages/ui/src/components/chat/PermissionRequest.tsx b/packages/ui/src/components/chat/PermissionRequest.tsx deleted file mode 100644 index c7c3e879..00000000 --- a/packages/ui/src/components/chat/PermissionRequest.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission'; -import * as sessionActions from '@/sync/session-actions'; -import { useI18n } from '@/lib/i18n'; -import { Icon } from "@/components/icon/Icon"; - -interface PermissionRequestProps { - permission: PermissionRequestPayload; - onResponse?: (response: 'once' | 'always' | 'reject') => void; -} - -export const PermissionRequest: React.FC = ({ - permission, - onResponse -}) => { - const { t } = useI18n(); - const [isResponding, setIsResponding] = React.useState(false); - const [hasResponded, setHasResponded] = React.useState(false); - const respondToPermission = sessionActions.respondToPermission; - - const handleResponse = async (response: PermissionResponse) => { - setIsResponding(true); - - try { - await respondToPermission(permission.sessionID, permission.id, response); - setHasResponded(true); - onResponse?.(response); - } catch (error) { - console.error('[PermissionRequest] Failed to respond to permission:', error); - } finally { - setIsResponding(false); - } - }; - - if (hasResponded) { - return null; - } - - const command = typeof permission.metadata.command === 'string' - ? permission.metadata.command - : (permission.patterns?.[0] ?? permission.permission); - - return ( -
-
-
- - {t('chat.permissionRequest.required')} - - - {command} - -
-
- -
- - - - - - - {isResponding && ( -
-
-
- )} -
-
- ); -}; diff --git a/packages/ui/src/components/chat/PermissionToastActions.tsx b/packages/ui/src/components/chat/PermissionToastActions.tsx deleted file mode 100644 index 60ebf802..00000000 --- a/packages/ui/src/components/chat/PermissionToastActions.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import { useI18n } from '@/lib/i18n'; - -interface PermissionToastActionsProps { - sessionTitle: string; - permissionBody: string; - disabled?: boolean; - onOnce: () => Promise | void; - onAlways: () => Promise | void; - onDeny: () => Promise | void; -} - -const truncateToastText = (value: string, maxLength: number): string => { - const normalized = value.trim(); - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`; -}; - -export const PermissionToastActions: React.FC = ({ - sessionTitle, - permissionBody, - disabled = false, - onOnce, - onAlways, - onDeny, -}) => { - const { t } = useI18n(); - const [isBusy, setIsBusy] = React.useState(false); - const hasSessionTitle = sessionTitle.trim().length > 0; - const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback'); - const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback'); - - const handleAction = async (action: () => Promise | void) => { - if (isBusy || disabled) return; - setIsBusy(true); - try { - await action(); - } finally { - setIsBusy(false); - } - }; - - return ( -
-
-

- {t('chat.permissionToast.labels.session')}{' '} - - {sessionPreview} - -

-

- {t('chat.permissionToast.labels.permission')}{' '} - - {permissionPreview} - -

-
- -
- - - - - -
-
- ); -}; diff --git a/packages/ui/src/components/chat/StatusChip.tsx b/packages/ui/src/components/chat/StatusChip.tsx deleted file mode 100644 index e0076477..00000000 --- a/packages/ui/src/components/chat/StatusChip.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useContextStore } from '@/stores/contextStore'; -import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils'; -import { useI18n } from '@/lib/i18n'; - -const STATUS_CHIP_STYLE = { - height: '28px', - maxHeight: '28px', - minHeight: '28px', -}; - -interface StatusChipProps { - onClick: () => void; - className?: string; -} - -export const StatusChip: React.FC = ({ onClick, className }) => { - const { t } = useI18n(); - const currentModelId = useConfigStore((state) => state.currentModelId); - const currentVariant = useConfigStore((state) => state.currentVariant); - const currentAgentName = useConfigStore((state) => state.currentAgentName); - const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); - const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); - const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessionAgentName = useContextStore((state) => - currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null - ); - - const agents = getVisibleAgents(); - const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; - const agentLabel = getAgentDisplayName(agents, uiAgentName); - const currentProvider = getCurrentProvider(); - const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel')); - const hasEffort = getCurrentModelVariants().length > 0; - const effortLabel = hasEffort ? formatEffortLabel(currentVariant) : null; - const fullLabel = [agentLabel, modelLabel, effortLabel].filter(Boolean).join(' · '); - - return ( - - ); -}; - -export default StatusChip; diff --git a/packages/ui/src/components/chat/StreamingTextDiff.tsx b/packages/ui/src/components/chat/StreamingTextDiff.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/ui/src/components/chat/components/TurnList.tsx b/packages/ui/src/components/chat/components/TurnList.tsx deleted file mode 100644 index be2fd0dd..00000000 --- a/packages/ui/src/components/chat/components/TurnList.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; - -interface TurnListEntry { - key: string; -} - -interface TurnListProps { - entries: TEntry[]; - renderEntry: (entry: TEntry) => React.ReactNode; -} - -const TurnList = ({ entries, renderEntry }: TurnListProps): React.ReactElement => { - return ( - <> - {entries.map((entry) => ( -
- {renderEntry(entry)} -
- ))} - - ); -}; - -export default React.memo(TurnList) as typeof TurnList; diff --git a/packages/ui/src/components/chat/composerHighlight.ts b/packages/ui/src/components/chat/composerHighlight.ts index 3fb9c88c..901d79fb 100644 --- a/packages/ui/src/components/chat/composerHighlight.ts +++ b/packages/ui/src/components/chat/composerHighlight.ts @@ -17,7 +17,7 @@ * with ordinary prose (`2 * 3`, `foo_bar`). */ -export type HighlightStyle = +type HighlightStyle = | 'marker' | 'code' | 'codeFence' @@ -27,7 +27,7 @@ export type HighlightStyle = | 'blockquote' | 'listMarker'; -export type MentionKind = 'file' | 'agent'; +type MentionKind = 'file' | 'agent'; export interface HighlightRange { start: number; diff --git a/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts b/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts index c69bf340..2854c18c 100644 --- a/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts +++ b/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts @@ -1,10 +1,10 @@ import React from 'react'; -export type ChatHashTarget = +type ChatHashTarget = | { kind: 'turn'; id: string } | { kind: 'message'; id: string }; -export const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => { +const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => { const value = hashValue.startsWith('#') ? hashValue.slice(1) : hashValue; if (!value) { return null; @@ -28,7 +28,7 @@ type TurnOffsetTarget = | { kind: 'resume' } | { kind: 'turn'; turnId: string }; -export const resolveTurnOffsetTarget = ( +const resolveTurnOffsetTarget = ( turnIds: string[], activeTurnId: string | null, offset: number, diff --git a/packages/ui/src/components/chat/hooks/useStreamingTextThrottle.ts b/packages/ui/src/components/chat/hooks/useStreamingTextThrottle.ts index 1a04e661..8d4175ac 100644 --- a/packages/ui/src/components/chat/hooks/useStreamingTextThrottle.ts +++ b/packages/ui/src/components/chat/hooks/useStreamingTextThrottle.ts @@ -9,7 +9,7 @@ interface UseStreamingTextThrottleInput { const DEFAULT_STREAMING_TEXT_THROTTLE_MS = 100; -export const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => { +const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => { const elapsed = now - lastEmitAt; return Math.max(0, throttleMs - elapsed); }; diff --git a/packages/ui/src/components/chat/hooks/useTurnLookup.ts b/packages/ui/src/components/chat/hooks/useTurnLookup.ts deleted file mode 100644 index e3ccf61c..00000000 --- a/packages/ui/src/components/chat/hooks/useTurnLookup.ts +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import type { TurnProjectionResult } from '../lib/turns/types'; - -export interface TurnLookupResult { - turnById: TurnProjectionResult['indexes']['turnById']; - messageToTurnId: TurnProjectionResult['indexes']['messageToTurnId']; - messageMetaById: TurnProjectionResult['indexes']['messageMetaById']; - getTurnByMessageId: (messageId: string) => TurnProjectionResult['turns'][number] | undefined; -} - -export const useTurnLookup = (projection: TurnProjectionResult): TurnLookupResult => { - const getTurnByMessageId = React.useCallback((messageId: string) => { - const turnId = projection.indexes.messageToTurnId.get(messageId); - if (!turnId) { - return undefined; - } - return projection.indexes.turnById.get(turnId); - }, [projection.indexes.messageToTurnId, projection.indexes.turnById]); - - return { - turnById: projection.indexes.turnById, - messageToTurnId: projection.indexes.messageToTurnId, - messageMetaById: projection.indexes.messageMetaById, - getTurnByMessageId, - }; -}; diff --git a/packages/ui/src/components/chat/lib/blockingRequests.ts b/packages/ui/src/components/chat/lib/blockingRequests.ts deleted file mode 100644 index 8da5305e..00000000 --- a/packages/ui/src/components/chat/lib/blockingRequests.ts +++ /dev/null @@ -1,61 +0,0 @@ -interface SessionLinkRecord { - id: string; - parentID?: string; -} - -export const collectVisibleSessionIdsForBlockingRequests = ( - sessions: SessionLinkRecord[] | undefined, - currentSessionId: string | null, -): string[] => { - if (!currentSessionId) return []; - if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId]; - - const current = sessions.find((session) => session.id === currentSessionId); - if (!current) return [currentSessionId]; - - const childrenByParent = new Map(); - for (const session of sessions) { - if (!session.parentID) { - continue; - } - const existing = childrenByParent.get(session.parentID) ?? []; - existing.push(session.id); - childrenByParent.set(session.parentID, existing); - } - - const scoped = [currentSessionId]; - const seen = new Set(scoped); - for (const sessionId of scoped) { - const children = childrenByParent.get(sessionId) ?? []; - for (const childId of children) { - if (seen.has(childId)) { - continue; - } - seen.add(childId); - scoped.push(childId); - } - } - - return scoped; -}; - -export const flattenBlockingRequests = ( - source: Map, - sessionIds: string[], -): T[] => { - if (sessionIds.length === 0) return []; - const seen = new Set(); - const result: T[] = []; - - for (const sessionId of sessionIds) { - const entries = source.get(sessionId); - if (!entries || entries.length === 0) continue; - for (const entry of entries) { - if (seen.has(entry.id)) continue; - seen.add(entry.id); - result.push(entry); - } - } - - return result; -}; diff --git a/packages/ui/src/components/chat/lib/scroll/scrollIntent.ts b/packages/ui/src/components/chat/lib/scroll/scrollIntent.ts deleted file mode 100644 index 0d3e6100..00000000 --- a/packages/ui/src/components/chat/lib/scroll/scrollIntent.ts +++ /dev/null @@ -1,78 +0,0 @@ -export const normalizeWheelDelta = (input: { - deltaY: number; - deltaMode: number; - rootHeight?: number; -}): number => { - if (input.deltaMode === 1) { - return input.deltaY * 40; - } - if (input.deltaMode === 2) { - return input.deltaY * (input.rootHeight ?? 120); - } - return input.deltaY; -}; - -export const shouldMarkBoundaryGesture = (input: { - delta: number; - scrollTop: number; - scrollHeight: number; - clientHeight: number; -}): boolean => { - const max = input.scrollHeight - input.clientHeight; - if (max <= 1) { - return true; - } - - if (!input.delta) { - return false; - } - - if (input.delta < 0) { - return input.scrollTop + input.delta <= 0; - } - - const remaining = max - input.scrollTop; - return input.delta > remaining; -}; - -export const boundaryTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement => { - const current = target instanceof Element ? target : undefined; - const nested = current?.closest('[data-scrollable]'); - if (!nested || nested === root) { - return root; - } - if (!(nested instanceof HTMLElement)) { - return root; - } - return nested; -}; - -export const shouldPauseAutoScrollOnWheel = (input: { - root: HTMLElement; - target: EventTarget | null; - delta: number; -}): boolean => { - if (input.delta >= 0) { - return false; - } - - const target = boundaryTarget(input.root, input.target); - if (target === input.root) { - return true; - } - - return shouldMarkBoundaryGesture({ - delta: input.delta, - scrollTop: target.scrollTop, - scrollHeight: target.scrollHeight, - clientHeight: target.clientHeight, - }); -}; - -export const isNearTop = (scrollTop: number, threshold: number): boolean => { - return scrollTop <= threshold; -}; - -export const isNearBottom = (distanceFromBottom: number, threshold: number): boolean => { - return distanceFromBottom <= threshold; -}; diff --git a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts index 94acb55c..3e50e9f2 100644 --- a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts +++ b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts @@ -18,7 +18,7 @@ type ScrollSpyInput = { MutationObserver?: typeof globalThis.MutationObserver; }; -export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => { +const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => { if (list.length === 0) { return undefined; } @@ -40,7 +40,7 @@ export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | u return sorted[0]?.id; }; -export const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => { +const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => { if (list.length === 0) { return undefined; } diff --git a/packages/ui/src/components/chat/lib/turns/constants.ts b/packages/ui/src/components/chat/lib/turns/constants.ts index 8b8e5bdb..75277c68 100644 --- a/packages/ui/src/components/chat/lib/turns/constants.ts +++ b/packages/ui/src/components/chat/lib/turns/constants.ts @@ -1,5 +1 @@ export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']); - -export const HIDDEN_INTERNAL_TOOL_NAMES = new Set(['todowrite', 'todoread']); - -export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100; diff --git a/packages/ui/src/components/chat/lib/turns/historySignals.ts b/packages/ui/src/components/chat/lib/turns/historySignals.ts index d9365ca1..b406a639 100644 --- a/packages/ui/src/components/chat/lib/turns/historySignals.ts +++ b/packages/ui/src/components/chat/lib/turns/historySignals.ts @@ -1,67 +1,6 @@ -import type { SessionMemoryState } from '@/sync/viewport-store'; - -export interface TurnHistorySignalsInput { - memoryState: SessionMemoryState | null; - loadedMessageCount: number; - loadedTurnCount: number; - turnStart: number; - defaultHistoryLimit: number; -} - export interface TurnHistorySignals { hasBufferedTurns: boolean; hasMoreAboveTurns: boolean; historyLoading: boolean; canLoadEarlier: boolean; } - -const deriveHasMoreAbove = ( - memoryState: SessionMemoryState | null, - loadedMessageCount: number, - loadedTurnCount: number, - defaultHistoryLimit: number, -): boolean => { - if (!memoryState) { - return loadedMessageCount >= defaultHistoryLimit; - } - - if (memoryState.historyComplete === true) { - return false; - } - - if (memoryState.hasMoreTurnsAbove === true || memoryState.hasMoreAbove === true) { - return true; - } - - if (memoryState.historyComplete === false) { - return true; - } - - if (memoryState.hasMoreTurnsAbove === false || memoryState.hasMoreAbove === false) { - return false; - } - - const fallbackMessageSignal = loadedMessageCount >= defaultHistoryLimit; - const fallbackTurnSignal = loadedTurnCount >= Math.max(1, Math.floor(defaultHistoryLimit / 2)); - return fallbackMessageSignal || fallbackTurnSignal; -}; - -export const deriveTurnHistorySignals = ( - input: TurnHistorySignalsInput, -): TurnHistorySignals => { - const hasBufferedTurns = input.turnStart > 0; - const hasMoreAboveTurns = deriveHasMoreAbove( - input.memoryState, - input.loadedMessageCount, - input.loadedTurnCount, - input.defaultHistoryLimit, - ); - const historyLoading = Boolean(input.memoryState?.historyLoading); - - return { - hasBufferedTurns, - hasMoreAboveTurns, - historyLoading, - canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns, - }; -}; diff --git a/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts b/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts deleted file mode 100644 index 8a0d4685..00000000 --- a/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { projectTurnIndexes } from './projectTurnIndexes'; -import type { TurnProjectionResult, TurnRecord } from './types'; - -const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => { - if (previousTurn.userMessage !== nextTurn.userMessage) { - return false; - } - - if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) { - return false; - } - - for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) { - if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) { - return false; - } - } - - return true; -}; - -const buildTurnSignature = (turn: TurnRecord): string => { - const assistantIds = turn.assistantMessageIds.join(','); - return [ - turn.turnId, - turn.headerMessageId ?? '', - assistantIds, - turn.summaryText ?? '', - turn.stream.isStreaming ? '1' : '0', - turn.stream.isRetrying ? '1' : '0', - turn.completedAt ?? '', - ].join('|'); -}; - -export const stabilizeTurnProjection = ( - nextProjection: TurnProjectionResult, - previousProjection: TurnProjectionResult | null, -): TurnProjectionResult => { - if (!previousProjection || previousProjection.turns.length === 0 || nextProjection.turns.length === 0) { - return nextProjection; - } - - const previousById = new Map(previousProjection.turns.map((turn) => [turn.turnId, turn])); - let reused = false; - - const stabilizedTurns = nextProjection.turns.map((turn, index) => { - const isLastTurn = index === nextProjection.turns.length - 1; - if (isLastTurn) { - return turn; - } - - const previousTurn = previousById.get(turn.turnId); - if (!previousTurn) { - return turn; - } - - if (buildTurnSignature(previousTurn) !== buildTurnSignature(turn)) { - return turn; - } - - if (!areTurnMessagesReferenceStable(previousTurn, turn)) { - return turn; - } - - reused = true; - return previousTurn; - }); - - if (!reused) { - return nextProjection; - } - - const projection = projectTurnIndexes(stabilizedTurns); - return { - ...projection, - ungroupedMessageIds: nextProjection.ungroupedMessageIds, - }; -}; diff --git a/packages/ui/src/components/chat/lib/turns/stageTurns.ts b/packages/ui/src/components/chat/lib/turns/stageTurns.ts deleted file mode 100644 index c5e7807c..00000000 --- a/packages/ui/src/components/chat/lib/turns/stageTurns.ts +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; - -export interface TurnStageConfig { - init: number; - batch: number; -} - -export interface UseStageTurnsOptions { - sessionKey: string; - turnStart: number; - totalTurns: number; - config?: Partial; - disabled?: boolean; -} - -export interface StageTurnsResult { - stagedCount: number; - stageStartIndex: number; - isStaging: boolean; -} - -const DEFAULT_STAGE_CONFIG: TurnStageConfig = { - init: 10, - batch: 8, -}; - -export const getInitialStageCount = (total: number, config: TurnStageConfig): number => { - if (total <= 0) { - return 0; - } - return Math.min(total, Math.max(1, config.init)); -}; - -export const getNextStageCount = (current: number, total: number, config: TurnStageConfig): number => { - if (total <= 0) { - return 0; - } - const batch = Math.max(1, config.batch); - return Math.min(total, current + batch); -}; - -export const getStageStartIndex = (total: number, stagedCount: number): number => { - if (stagedCount >= total) { - return 0; - } - return Math.max(0, total - stagedCount); -}; - -export const useStageTurns = ({ - sessionKey, - turnStart, - totalTurns, - config, - disabled, -}: UseStageTurnsOptions): StageTurnsResult => { - const effectiveConfig = React.useMemo(() => { - return { - init: config?.init ?? DEFAULT_STAGE_CONFIG.init, - batch: config?.batch ?? DEFAULT_STAGE_CONFIG.batch, - }; - }, [config?.batch, config?.init]); - - const [state, setState] = React.useState(() => ({ - activeSession: '', - completedSession: '', - count: totalTurns, - })); - - const stateRef = React.useRef(state); - React.useEffect(() => { - stateRef.current = state; - }, [state]); - - React.useEffect(() => { - let frameId: number | null = null; - const snapshot = stateRef.current; - const shouldStage = - !disabled - && turnStart > 0 - && totalTurns > effectiveConfig.init - && snapshot.completedSession !== sessionKey - && snapshot.activeSession !== sessionKey; - - if (!shouldStage) { - setState((previous) => { - if (previous.count === totalTurns && previous.activeSession === '') { - return previous; - } - return { - ...previous, - activeSession: '', - count: totalTurns, - }; - }); - return () => { - if (frameId !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(frameId); - } - }; - } - - let nextCount = getInitialStageCount(totalTurns, effectiveConfig); - setState((previous) => ({ - ...previous, - activeSession: sessionKey, - count: nextCount, - })); - - const step = () => { - nextCount = getNextStageCount(nextCount, totalTurns, effectiveConfig); - setState((previous) => ({ - ...previous, - count: nextCount, - })); - - if (nextCount >= totalTurns) { - setState((previous) => ({ - ...previous, - completedSession: sessionKey, - activeSession: '', - count: totalTurns, - })); - frameId = null; - return; - } - - frameId = window.requestAnimationFrame(step); - }; - - if (typeof window !== 'undefined') { - frameId = window.requestAnimationFrame(step); - } - - return () => { - if (frameId !== null && typeof window !== 'undefined') { - window.cancelAnimationFrame(frameId); - } - }; - }, [disabled, effectiveConfig, sessionKey, totalTurns, turnStart]); - - const stagedCount = React.useMemo(() => { - if (turnStart <= 0 || disabled) { - return totalTurns; - } - if (state.completedSession === sessionKey) { - return totalTurns; - } - if (state.count <= 0) { - return getInitialStageCount(totalTurns, effectiveConfig); - } - return Math.min(totalTurns, state.count); - }, [disabled, effectiveConfig, sessionKey, state.completedSession, state.count, totalTurns, turnStart]); - - return { - stagedCount, - stageStartIndex: getStageStartIndex(totalTurns, stagedCount), - isStaging: !disabled && turnStart > 0 && state.activeSession === sessionKey && state.completedSession !== sessionKey, - }; -}; diff --git a/packages/ui/src/components/chat/lib/turns/types.ts b/packages/ui/src/components/chat/lib/turns/types.ts index f7306969..12b3ff52 100644 --- a/packages/ui/src/components/chat/lib/turns/types.ts +++ b/packages/ui/src/components/chat/lib/turns/types.ts @@ -5,7 +5,7 @@ export interface ChatMessageEntry { parts: Part[]; } -export type TurnActivityKind = 'tool' | 'reasoning' | 'justification'; +type TurnActivityKind = 'tool' | 'reasoning' | 'justification'; export interface TurnMessageRecord { messageId: string; @@ -83,7 +83,7 @@ export interface TurnRecord { durationMs?: number; } -export interface TurnMessageMeta { +interface TurnMessageMeta { turnId: string; messageId: string; userMessageId: string; diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 2e8467e4..f933b361 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -140,13 +140,13 @@ const extractTableData = (table: HTMLTableElement): { headers: string[]; rows: s const escapeCsv = (value: string): string => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; -export const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => +const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => [headers, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n'); -export const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => +const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => [headers, ...rows].map((row) => row.join('\t')).join('\n'); -export const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => { +const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => { const head = `| ${headers.join(' | ')} |`; const sep = `| ${headers.map(() => '---').join(' | ')} |`; const body = rows.map((row) => `| ${row.join(' | ')} |`).join('\n'); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 4fc1ce19..250fc1cd 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -13,7 +13,7 @@ const escapeAttr = (value: string): string => // Streaming block segmentation (port of OpenCode's markdown-stream) // --------------------------------------------------------------------------- -export type MarkdownBlock = { +type MarkdownBlock = { raw: string; src: string; mode: 'full' | 'live'; @@ -54,7 +54,7 @@ const heal = (text: string): string => { * unclosed trailing code fence into its own `live` block so a partial fence * does not corrupt the parse of stable content above it. */ -export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => { +const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => { if (!live) return [{ raw: text, src: text, mode: 'full', highlight: true }]; // Reference-style links/footnotes span multiple tokens (definition elsewhere); // keep them as a single block so per-block parsing doesn't break the refs. diff --git a/packages/ui/src/components/chat/markdown/markdownTheme.ts b/packages/ui/src/components/chat/markdown/markdownTheme.ts index f78161cd..30044c8e 100644 --- a/packages/ui/src/components/chat/markdown/markdownTheme.ts +++ b/packages/ui/src/components/chat/markdown/markdownTheme.ts @@ -6,7 +6,7 @@ import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdow // `--md-syntax-*` CSS variables) lives in the dependency-free // `markdownShikiThemeDefinition` module so it can also be imported inside the // Shiki Web Worker. See that module for the rationale. -export { MARKDOWN_SHIKI_THEME }; + let registered = false; diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index d9fba8d9..5ecf206e 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -717,5 +717,3 @@ export const TextSelectionMenu: React.FC = ({ containerR document.body ); }; - -export default TextSelectionMenu; diff --git a/packages/ui/src/components/chat/message/partUtils.ts b/packages/ui/src/components/chat/message/partUtils.ts index 567dcff8..1f95dee6 100644 --- a/packages/ui/src/components/chat/message/partUtils.ts +++ b/packages/ui/src/components/chat/message/partUtils.ts @@ -2,7 +2,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; type PartWithText = Part & { text?: string; content?: string; value?: string }; -export const isValidPart = (part: unknown): part is Part => { +const isValidPart = (part: unknown): part is Part => { return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string'); }; @@ -67,13 +67,3 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions return !isPatchPart; }); }; - -type PartWithTime = Part & { time?: { start?: number; end?: number } }; - -export const isFinalizedTextPart = (part: Part): boolean => { - if (part.type !== 'text') { - return false; - } - const time = (part as PartWithTime).time; - return Boolean(time && typeof time.end !== 'undefined'); -}; diff --git a/packages/ui/src/components/chat/message/parts/MigratingPart.tsx b/packages/ui/src/components/chat/message/parts/MigratingPart.tsx deleted file mode 100644 index 54f71d3e..00000000 --- a/packages/ui/src/components/chat/message/parts/MigratingPart.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; - -interface MigratingPartProps { - - isMigrating: boolean; - children: React.ReactNode; - className?: string; -} - -const MigratingPart: React.FC = ({ - isMigrating, - children, - className, -}) => { - return ( -
- {children} -
- ); -}; - -export default React.memo(MigratingPart); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index af7e56e5..ee0443f3 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -18,7 +18,7 @@ const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; -export type ReasoningVariant = 'thinking' | 'justification'; +type ReasoningVariant = 'thinking' | 'justification'; const cleanReasoningText = (text: string): string => { if (typeof text !== 'string' || text.trim().length === 0) { @@ -536,7 +536,4 @@ export const MergedReasoningPart = React.memo(({ ); }); -// eslint-disable-next-line react-refresh/only-export-components -export const formatReasoningText = (text: string): string => cleanReasoningText(text); - export default ReasoningPart; diff --git a/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx b/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx deleted file mode 100644 index 9087bf29..00000000 --- a/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React from 'react'; - -/** - * 5x5 grid letter patterns (indices 0-24). - * Grid layout: - * 0 1 2 3 4 - * 5 6 7 8 9 - * 10 11 12 13 14 - * 15 16 17 18 19 - * 20 21 22 23 24 - * - * Each letter is represented as an array of "on" cell indices. - */ -const LETTER_PATTERNS: Record = { - // 0 1 2 3 4 - // 5 6 7 8 9 - // 10 11 12 13 14 - // 15 16 17 18 19 - // 20 21 22 23 24 - A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24], - B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23], - C: [1, 2, 3, 5, 10, 15, 21, 22, 23], - D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23], - E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23], - F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20], - G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23], - H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24], - I: [1, 2, 3, 7, 12, 17, 21, 22, 23], - J: [1, 2, 3, 8, 13, 15, 18, 21, 22], - K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23], - L: [0, 5, 10, 15, 20, 21, 22, 23], - M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24], - N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24], - O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23], - P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20], - Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24], - R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23], - S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23], - T: [0, 1, 2, 3, 4, 7, 12, 17, 22], - U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23], - V: [0, 4, 5, 9, 10, 14, 16, 18, 22], - W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23], - X: [0, 4, 6, 8, 12, 16, 18, 20, 24], - Y: [0, 4, 6, 8, 12, 17, 22], - Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24], - '0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23], - '1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24], - '2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24], - '3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23], - '4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24], - '5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23], - '6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23], - '7': [0, 1, 2, 3, 4, 9, 13, 17, 22], - '8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23], - '9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23], - ' ': [], -}; - -// Build Set versions for O(1) lookups -const LETTER_SETS: Record> = {}; -for (const [key, indices] of Object.entries(LETTER_PATTERNS)) { - LETTER_SETS[key] = new Set(indices); -} - -/** Duration each letter is displayed (ms) */ -const LETTER_DURATION_MS = 800; -/** Crossfade transition duration (ms) */ -const TRANSITION_MS = 500; -/** Pause between full cycles (ms) */ -const CYCLE_PAUSE_MS = 1000; - -/** Spacing between dot centers in SVG units */ -const DOT_SPACING = 4; -/** Dot radius */ -const DOT_RADIUS = 1.2; - -/** - * Octagonal grid layout (7 rows): - * - * • • • row 0: 3 dots (cols 2-4) - * • • • • • row 1: 5 dots (cols 1-5) → letter row 0 - * • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1 - * • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2 - * • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3 - * • • • • • row 5: 5 dots (cols 1-5) → letter row 4 - * • • • row 6: 3 dots (cols 2-4) - * - * Letter indices (0-24) map to the inner 5x5 zone: - * rows 1-5, cols 1-5 - */ -const OCTAGON_ROWS: { row: number; cols: number[] }[] = [ - { row: 0, cols: [2, 3, 4] }, - { row: 1, cols: [1, 2, 3, 4, 5] }, - { row: 2, cols: [0, 1, 2, 3, 4, 5, 6] }, - { row: 3, cols: [0, 1, 2, 3, 4, 5, 6] }, - { row: 4, cols: [0, 1, 2, 3, 4, 5, 6] }, - { row: 5, cols: [1, 2, 3, 4, 5] }, - { row: 6, cols: [2, 3, 4] }, -]; - -interface OctCell { - id: number; - cx: number; - cy: number; - /** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */ - letterIndex: number; - // Stable random timing - shimmerDuration: number; - shimmerDelay: number; - idleDuration: number; - idleDelay: number; -} - -const CELLS: OctCell[] = []; -let cellId = 0; -for (const { row, cols } of OCTAGON_ROWS) { - for (const col of cols) { - const cx = col * DOT_SPACING; - const cy = row * DOT_SPACING; - - // Letter zone: rows 1-5 (octagon), cols 1-5 (octagon) - // maps to 5x5 letter index - let letterIndex = -1; - const letterRow = row - 1; - const letterCol = col - 1; - if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) { - letterIndex = letterRow * 5 + letterCol; - } - - CELLS.push({ - id: cellId++, - cx, - cy, - letterIndex, - shimmerDuration: 3 + Math.random() * 3, - shimmerDelay: Math.random() * 3, - idleDuration: 1 + Math.random(), - idleDelay: Math.random() * 1.5, - }); - } -} - -const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2; -const VIEW_OFFSET = -DOT_RADIUS; - -interface SessionActiveSpinnerProps { - className?: string; - /** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */ - text?: string; -} - -/** - * Idle mode: random pulsing octagonal dot grid. - * Text mode: cycles through characters of `text`, morphing between letter shapes. - */ -export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) { - const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || ''; - const hasText = normalizedText.length > 0; - - const [charIndex, setCharIndex] = React.useState(0); - const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold'); - - // Intro fade: foreground starts invisible and fades in - const [introReady, setIntroReady] = React.useState(false); - React.useEffect(() => { - const id = requestAnimationFrame(() => setIntroReady(true)); - return () => cancelAnimationFrame(id); - }, []); - - // Reset on text change - React.useEffect(() => { - setCharIndex(0); - setPhase('hold'); - }, [normalizedText]); - - // Letter cycling timer - React.useEffect(() => { - if (!hasText) return; - - const total = normalizedText.length; - - if (phase === 'hold') { - const isLastChar = charIndex === total - 1; - const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0); - const timer = setTimeout(() => setPhase('morph'), delay); - return () => clearTimeout(timer); - } - - const timer = setTimeout(() => { - setCharIndex((prev) => (prev + 1) % total); - setPhase('hold'); - }, TRANSITION_MS); - return () => clearTimeout(timer); - }, [hasText, charIndex, normalizedText, phase]); - - // Compute current and next letter sets for morphing - const total = normalizedText.length; - const currentSet = hasText - ? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' ']) - : null; - const nextIndex = hasText ? (charIndex + 1) % total : 0; - const nextSet = hasText - ? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' ']) - : null; - - return ( - - ); -} diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts index c499bf6c..783a20ef 100644 --- a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts @@ -1,11 +1,11 @@ -export type GeneratedCommitResult = { +type GeneratedCommitResult = { kind: 'commit'; subject: string; highlights: string[]; raw: string; }; -export type GeneratedPrResult = { +type GeneratedPrResult = { kind: 'pr'; title: string; body: string; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index ea8dd07b..b54305a8 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -18,7 +18,7 @@ const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null; }; -export const normalizePatchText = (patch: string): string => { +const normalizePatchText = (patch: string): string => { return patch.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); }; diff --git a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts index c012ab10..f577ca9d 100644 --- a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts @@ -7,8 +7,6 @@ const EXPANDABLE_TOOL_NAMES = new Set([ const STANDALONE_TOOL_NAMES = new Set(['task']); -const SEARCH_TOOL_NAMES = new Set(['grep', 'search', 'find', 'ripgrep', 'glob']); - const normalizeToolName = (toolName: unknown): string => { if (typeof toolName !== 'string') return ''; const trimmed = toolName.trim().toLowerCase(); @@ -34,11 +32,3 @@ export const isStaticTool = (toolName: unknown): boolean => { if (typeof toolName !== 'string') return false; return !isExpandableTool(toolName) && !isStandaloneTool(toolName); }; - -export const getStaticGroupToolName = (toolName: string): string => { - const normalized = normalizeToolName(toolName); - if (SEARCH_TOOL_NAMES.has(normalized)) { - return 'grep'; - } - return normalized; -}; diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts index 9101b59d..c65708ee 100644 --- a/packages/ui/src/components/chat/message/renderCompare.ts +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -106,7 +106,7 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea return true; }; -export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => { +const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => { if (left === right) return true; return left.id === right.id diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index 785dfa84..a781f35b 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -11,7 +11,7 @@ const cleanOutput = (output: string) => { return cleaned.trim(); }; -export const hasLspDiagnostics = (output: string): boolean => { +const hasLspDiagnostics = (output: string): boolean => { if (!output) return false; return output.includes('') @@ -124,7 +124,7 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re return cleaned; }; -export interface ParsedReadOutputLine { +interface ParsedReadOutputLine { text: string; lineNumber: number | null; isInfo: boolean; @@ -528,9 +528,9 @@ export const renderWebSearchOutput = (output: string, options?: { unstyled?: boo } }; -export type DiffLineType = 'context' | 'added' | 'removed'; +type DiffLineType = 'context' | 'added' | 'removed'; -export interface UnifiedDiffLine { +interface UnifiedDiffLine { type: DiffLineType; lineNumber: number | null; content: string; @@ -543,18 +543,6 @@ export interface UnifiedDiffHunk { lines: UnifiedDiffLine[]; } -export interface SideBySideDiffLine { - leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string }; - rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string }; -} - -export interface SideBySideDiffHunk { - file: string; - oldStart: number; - newStart: number; - lines: SideBySideDiffLine[]; -} - export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => { const lines = diffText.split('\n'); let currentFile = ''; @@ -615,199 +603,6 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => { return hunks; }; -export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => { - const lines = diffText.split('\n'); - let currentFile = ''; - const hunks: SideBySideDiffHunk[] = []; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - - if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) { - if (line.startsWith('Index:')) { - currentFile = line.split(' ')[1].split('/').pop() || 'file'; - } - i++; - continue; - } - - if (line.startsWith('@@')) { - const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); - const oldStart = match ? parseInt(match[1]) : 0; - const newStart = match ? parseInt(match[2]) : 0; - - const changes: Array<{ - type: 'context' | 'added' | 'removed'; - content: string; - oldLine?: number; - newLine?: number; - }> = []; - - let oldLineNum = oldStart; - let newLineNum = newStart; - let j = i + 1; - - while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) { - const contentLine = lines[j]; - if (contentLine.startsWith('+')) { - changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum }); - newLineNum++; - } else if (contentLine.startsWith('-')) { - changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum }); - oldLineNum++; - } else if (contentLine.startsWith(' ')) { - changes.push({ - type: 'context', - content: contentLine.substring(1), - oldLine: oldLineNum, - newLine: newLineNum, - }); - oldLineNum++; - newLineNum++; - } - j++; - } - - const alignedLines: Array<{ - leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string }; - rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string }; - }> = []; - - const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = []; - const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = []; - - changes.forEach((change) => { - if (change.type === 'context') { - leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content }); - rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content }); - } else if (change.type === 'removed') { - leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content }); - } else if (change.type === 'added') { - rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content }); - } - }); - - const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = []; - - leftSide.forEach((leftItem, leftIdx) => { - if (leftItem.type === 'context') { - const rightIdx = rightSide.findIndex((rightItem, rIdx) => - rightItem.type === 'context' && - rightItem.content === leftItem.content && - !alignmentPoints.some((ap) => ap.rightIdx === rIdx) - ); - if (rightIdx >= 0) { - alignmentPoints.push({ leftIdx, rightIdx }); - } - } - }); - - alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx); - - let leftIdx = 0; - let rightIdx = 0; - let alignIdx = 0; - - while (leftIdx < leftSide.length || rightIdx < rightSide.length) { - const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null; - - if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) { - const leftItem = leftSide[leftIdx]; - const rightItem = rightSide[rightIdx]; - - alignedLines.push({ - leftLine: { - type: 'context', - lineNumber: leftItem.lineNumber, - content: leftItem.content, - }, - rightLine: { - type: 'context', - lineNumber: rightItem.lineNumber, - content: rightItem.content, - }, - }); - - leftIdx++; - rightIdx++; - alignIdx++; - } else { - const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx); - const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx); - - if (needProcessLeft && needProcessRight) { - const leftItem = leftSide[leftIdx]; - const rightItem = rightSide[rightIdx]; - - alignedLines.push({ - leftLine: { - type: leftItem.type, - lineNumber: leftItem.lineNumber, - content: leftItem.content, - }, - rightLine: { - type: rightItem.type, - lineNumber: rightItem.lineNumber, - content: rightItem.content, - }, - }); - - leftIdx++; - rightIdx++; - } else if (needProcessLeft) { - const leftItem = leftSide[leftIdx]; - alignedLines.push({ - leftLine: { - type: leftItem.type, - lineNumber: leftItem.lineNumber, - content: leftItem.content, - }, - rightLine: { - type: 'empty', - lineNumber: null, - content: '', - }, - }); - leftIdx++; - } else if (needProcessRight) { - const rightItem = rightSide[rightIdx]; - alignedLines.push({ - leftLine: { - type: 'empty', - lineNumber: null, - content: '', - }, - rightLine: { - type: rightItem.type, - lineNumber: rightItem.lineNumber, - content: rightItem.content, - }, - }); - rightIdx++; - } else { - break; - } - } - } - - hunks.push({ - file: currentFile, - oldStart, - newStart, - lines: alignedLines, - }); - - i = j; - continue; - } - - i++; - } - - return hunks; -}; - export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record) => { return detectToolOutputLanguage(toolName, output, input); }; diff --git a/packages/ui/src/components/chat/mobileControlsUtils.ts b/packages/ui/src/components/chat/mobileControlsUtils.ts index 8bfd598f..cc89a990 100644 --- a/packages/ui/src/components/chat/mobileControlsUtils.ts +++ b/packages/ui/src/components/chat/mobileControlsUtils.ts @@ -5,7 +5,7 @@ export type MobileControlsPanel = 'model' | 'agent' | 'variant' | null; export const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; -export const getCyclablePrimaryAgents = (agents: Agent[]) => agents.filter((agent) => isPrimaryMode(agent.mode)); +const getCyclablePrimaryAgents = (agents: Agent[]) => agents.filter((agent) => isPrimaryMode(agent.mode)); export const getCycledPrimaryAgentName = ( agents: Agent[], @@ -23,7 +23,7 @@ export const getCycledPrimaryAgentName = ( return primaryAgents[nextIndex]?.name ?? null; }; -export const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); +const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); export const getAgentDisplayName = (agents: Agent[], agentName?: string) => { if (agentName) { @@ -55,58 +55,3 @@ export const formatEffortLabel = (variant?: string) => { } return capitalizeLabel(trimmed); }; - -export const DEFAULT_EFFORT_KEY = 'default'; - -export const serializeEffortVariant = (variant?: string) => { - const trimmed = typeof variant === 'string' ? variant.trim() : ''; - return trimmed.length > 0 ? trimmed : DEFAULT_EFFORT_KEY; -}; - -export const parseEffortVariant = (variant: string) => { - return variant === DEFAULT_EFFORT_KEY ? undefined : variant; -}; - -const EFFORT_RANKS: Record = { - max: 6, - maximum: 6, - xhigh: 5, - high: 4, - medium: 3, - default: 2, - low: 1, - min: 0, - minimal: 0, -}; - -export const getEffortRank = (variant?: string) => { - if (!variant || variant.trim().length === 0) { - return EFFORT_RANKS.default; - } - const normalized = variant.trim().toLowerCase(); - if (Object.prototype.hasOwnProperty.call(EFFORT_RANKS, normalized)) { - return EFFORT_RANKS[normalized]; - } - const numeric = Number.parseFloat(normalized); - return Number.isFinite(numeric) ? numeric : 0; -}; - -export const getQuickEffortOptions = (variants: string[]) => { - const options = new Map(); - options.set('default', undefined); - for (const variant of variants) { - options.set(variant, variant); - } - - const ordered = Array.from(options.values()).sort((a, b) => getEffortRank(b) - getEffortRank(a)); - if (ordered.length <= 4) { - return ordered; - } - - const top = ordered.slice(0, 3); - const lowest = ordered[ordered.length - 1]; - if (top.some((item) => item === lowest)) { - return top; - } - return [...top, lowest]; -}; diff --git a/packages/ui/src/components/chat/revertedMessageDockState.ts b/packages/ui/src/components/chat/revertedMessageDockState.ts index 4ae3b017..5c0ed541 100644 --- a/packages/ui/src/components/chat/revertedMessageDockState.ts +++ b/packages/ui/src/components/chat/revertedMessageDockState.ts @@ -1,7 +1,7 @@ import type { Message, Part } from '@opencode-ai/sdk/v2/client'; import type { State } from '@/sync/types'; -export type RevertedMessageRecord = { +type RevertedMessageRecord = { message: Message & { role: 'user' }; parts: Part[]; }; diff --git a/packages/ui/src/components/chat/useDraftStarters.ts b/packages/ui/src/components/chat/useDraftStarters.ts index 86a5789a..6a49b5fe 100644 --- a/packages/ui/src/components/chat/useDraftStarters.ts +++ b/packages/ui/src/components/chat/useDraftStarters.ts @@ -21,7 +21,7 @@ import { type DraftStarterType, } from '@/lib/draftStarters'; -export type StarterGroup = 'global' | 'project'; +type StarterGroup = 'global' | 'project'; export type ResolvedStarter = { id: string; diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8003c1b0..4ba3ccad 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -1098,278 +1098,6 @@ export function DesktopHostSwitcherDialog({ ); } -type DesktopHostSwitcherButtonProps = { - headerIconButtonClass: string; -}; - -export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) { - const { t } = useI18n(); - const [open, setOpen] = React.useState(false); - const [label, setLabel] = React.useState('Local'); - const [status, setStatus] = React.useState(null); - const [localOrigin, setLocalOrigin] = React.useState(() => getLocalOrigin()); - const attemptedDefaultSshConnectRef = React.useRef(false); - const [startupSshModal, setStartupSshModal] = React.useState<{ - open: boolean; - hostId: string | null; - hostLabel: string; - error: string | null; - connecting: boolean; - }>({ - open: false, - hostId: null, - hostLabel: '', - error: null, - connecting: false, - }); - - const connectDefaultSshInstance = React.useCallback(async ( - hostId: string, - hostLabel: string, - options?: { showProgress?: boolean }, - ): Promise => { - const showProgress = Boolean(options?.showProgress); - if (showProgress) { - setStartupSshModal({ - open: true, - hostId, - hostLabel, - error: null, - connecting: true, - }); - } - - try { - await desktopSshConnect(hostId); - const ready = await waitForSshReady(hostId, 45_000, () => {}); - const localUrl = normalizeHostUrl(ready.localUrl || ''); - if (!localUrl) { - throw new Error('Connected but missing forwarded URL'); - } - if (isElectronShell()) { - switchRuntimeEndpoint({ apiBaseUrl: localUrl, clientToken: null, runtimeKey: `ssh:${hostId}` }); - } else { - window.location.assign(toNavigationUrl(localUrl)); - } - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setStartupSshModal({ - open: true, - hostId, - hostLabel, - error: message, - connecting: false, - }); - return false; - } - }, []); - - const switchStartupToLocal = React.useCallback(async () => { - setStartupSshModal({ - open: false, - hostId: null, - hostLabel: '', - error: null, - connecting: false, - }); - - let nextLocalOrigin = localOrigin; - await desktopHostsGet() - .then((cfg) => { - if (cfg.localOrigin) { - nextLocalOrigin = cfg.localOrigin; - setLocalOrigin(cfg.localOrigin); - } - return desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID }); - }) - .catch(() => undefined); - - if (isElectronShell()) { - const clientToken = await getLocalClientToken(); - switchRuntimeEndpoint({ apiBaseUrl: nextLocalOrigin, clientToken: clientToken || null, runtimeKey: 'local' }); - } else { - window.location.assign(toNavigationUrl(nextLocalOrigin)); - } - }, [localOrigin]); - - const retryStartupSsh = React.useCallback(() => { - const hostId = startupSshModal.hostId; - if (!hostId) return; - void connectDefaultSshInstance(hostId, startupSshModal.hostLabel || 'SSH instance', { - showProgress: true, - }); - }, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]); - - React.useEffect(() => { - if (!isDesktopShell()) return; - - let cancelled = false; - const run = async () => { - try { - const cfg = await desktopHostsGet(); - const nextLocalOrigin = cfg.localOrigin || localOrigin; - if (cfg.localOrigin && cfg.localOrigin !== localOrigin) { - setLocalOrigin(cfg.localOrigin); - } - const local = buildLocalHost(nextLocalOrigin); - const all = [local, ...(cfg.hosts || [])]; - const current = resolveCurrentHost(all); - - if ( - !isElectronShell() && - !attemptedDefaultSshConnectRef.current && - current.id === LOCAL_HOST_ID && - cfg.defaultHostId && - cfg.defaultHostId !== LOCAL_HOST_ID - ) { - const sshCfg = await desktopSshInstancesGet().catch(() => ({ instances: [] })); - const defaultSsh = sshCfg.instances.find((instance) => instance.id === cfg.defaultHostId); - if (defaultSsh) { - attemptedDefaultSshConnectRef.current = true; - const hostLabel = redactSensitiveUrl( - defaultSsh.nickname?.trim() || defaultSsh.sshParsed?.destination || defaultSsh.id, - ); - const connected = await connectDefaultSshInstance(cfg.defaultHostId, hostLabel); - if (connected || cancelled) { - return; - } - } - } - - if (cancelled) return; - setLabel(redactSensitiveUrl(current.label || t('desktopHostSwitcher.instance.fallback'))); - const normalized = normalizeHostUrl(current.url); - if (!normalized) { - setStatus(null); - return; - } - const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); - if (cancelled) return; - setStatus(res.status); - } catch { - if (!cancelled) { - setLabel(t('desktopHostSwitcher.instance.fallback')); - setStatus(null); - } - } - }; - - void run(); - const interval = window.setInterval(() => { - // Skip polling when tab is hidden to reduce background work - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { - return; - } - void run(); - }, 10_000); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [connectDefaultSshInstance, localOrigin, t]); - - if (!isDesktopShell()) { - return null; - } - - const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); - const isCurrentlyLocal = runtimeApiBaseUrl - ? locationMatchesHost(runtimeApiBaseUrl, localOrigin) - : locationMatchesHost(window.location.href, localOrigin); - - const fallbackLabel = typeof window !== 'undefined' && window.location.hostname - ? window.location.hostname - : t('desktopHostSwitcher.instance.fallback'); - - const effectiveLabel = isCurrentlyLocal - ? t('desktopHostSwitcher.instance.local') - : label === 'Local' - ? fallbackLabel - : label; - const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel); - - return ( - <> - - - - - -

{t('desktopHostSwitcher.title')}

-
-
- - { - if (!nextOpen && startupSshModal.connecting) { - return; - } - if (!nextOpen) { - setStartupSshModal((prev) => ({ - ...prev, - open: false, - connecting: false, - })); - return; - } - setStartupSshModal((prev) => ({ ...prev, open: true })); - }} - > - - - {t('desktopHostSwitcher.startup.title')} - - {startupSshModal.connecting - ? t('desktopHostSwitcher.startup.connectingTo', { host: startupSshModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') }) - : startupSshModal.error || t('desktopHostSwitcher.startup.failed')} - - -
- - -
-
-
- - ); -} - export function DesktopHostSwitcherInline() { const [open, setOpen] = React.useState(false); const { t } = useI18n(); diff --git a/packages/ui/src/components/diagram/index.ts b/packages/ui/src/components/diagram/index.ts index 9dcc2d6c..0bfac6e5 100644 --- a/packages/ui/src/components/diagram/index.ts +++ b/packages/ui/src/components/diagram/index.ts @@ -1 +1 @@ -export { DiagramEditor, type DiagramEditorProps, type DiagramEditorHandle } from './DiagramEditor'; +export { DiagramEditor, type DiagramEditorHandle } from './DiagramEditor'; diff --git a/packages/ui/src/components/layout/RightSidebar.tsx b/packages/ui/src/components/layout/RightSidebar.tsx index 20b2986f..e6080f75 100644 --- a/packages/ui/src/components/layout/RightSidebar.tsx +++ b/packages/ui/src/components/layout/RightSidebar.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'; import { useUIStore, RIGHT_SIDEBAR_MIN_WIDTH, RIGHT_SIDEBAR_MAX_WIDTH } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; -export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420; +const RIGHT_SIDEBAR_CONTENT_WIDTH = 420; interface RightSidebarProps { isOpen: boolean; diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 41a48f74..edd79610 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; -export const SIDEBAR_CONTENT_WIDTH = 280; +const SIDEBAR_CONTENT_WIDTH = 280; const SIDEBAR_MIN_WIDTH = 280; const SIDEBAR_MAX_WIDTH = 500; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 528cc60c..a2dd707d 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -37,7 +37,8 @@ import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { updateDesktopSettings } from '@/lib/persistence'; import { formatTimeForPreference } from '@/lib/timeFormat'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; -import type { Session, UsageWindow } from '@/types'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { UsageWindow } from '@/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index 6da2036b..88fb1c2a 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -20,7 +20,7 @@ import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDis import { cn } from '@/lib/utils'; import type { ModelMetadata } from '@/types'; -export type ProviderModel = Record & { id?: string; name?: string }; +type ProviderModel = Record & { id?: string; name?: string }; export type ModelPickerProvider = { id: string; @@ -34,7 +34,7 @@ export type ModelPickerEntry = { modelID: string; }; -export type ModelPickerFavoriteEntry = ModelPickerEntry; +type ModelPickerFavoriteEntry = ModelPickerEntry; type HiddenModel = { providerID: string; modelID: string }; diff --git a/packages/ui/src/components/multirun/BranchSelector.tsx b/packages/ui/src/components/multirun/BranchSelector.tsx index ca74c65f..8b86ac66 100644 --- a/packages/ui/src/components/multirun/BranchSelector.tsx +++ b/packages/ui/src/components/multirun/BranchSelector.tsx @@ -17,12 +17,6 @@ import { useI18n } from '@/lib/i18n'; /** localStorage key matching NewWorktreeDialog */ const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch'; -export type WorktreeBaseOption = { - value: string; - label: string; - group: 'special' | 'local' | 'remote'; -}; - export interface BranchSelectorProps { /** Current directory to check for git repository */ directory: string | null; diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index 0c9a51c7..5514711b 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -22,14 +22,6 @@ export interface ModelSelectionWithId { instanceId: string; } -/** Model selection without instanceId (for external use) */ -export interface ModelSelection { - providerID: string; - modelID: string; - displayName?: string; - variant?: string; -} - // eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect export const generateInstanceId = (): string => { return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -39,7 +31,7 @@ export const generateInstanceId = (): string => { * Model selection chip with remove button. * Shows instance index (e.g., "(2)") when same model is selected multiple times. */ -export const ModelChip: React.FC<{ +const ModelChip: React.FC<{ model: ModelSelectionWithId; instanceIndex: number; totalSameModel: number; diff --git a/packages/ui/src/components/multirun/index.ts b/packages/ui/src/components/multirun/index.ts index 7ad492e6..9a7cb7c1 100644 --- a/packages/ui/src/components/multirun/index.ts +++ b/packages/ui/src/components/multirun/index.ts @@ -1,4 +1 @@ export { MultiRunLauncher } from './MultiRunLauncher'; -export { ModelMultiSelect, ModelChip, generateInstanceId, type ModelSelectionWithId, type ModelSelection, type ModelMultiSelectProps } from './ModelMultiSelect'; -export { BranchSelector, useBranchOptions, type BranchSelectorProps, type BranchSelectorState, type WorktreeBaseOption } from './BranchSelector'; -export { AgentSelector, type AgentSelectorProps } from './AgentSelector'; diff --git a/packages/ui/src/components/sections/SectionPlaceholder.tsx b/packages/ui/src/components/sections/SectionPlaceholder.tsx deleted file mode 100644 index 394f5dc1..00000000 --- a/packages/ui/src/components/sections/SectionPlaceholder.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/constants/sidebar'; -import type { SidebarSection } from '@/constants/sidebar'; -import { useI18n } from '@/lib/i18n'; -import { Icon } from "@/components/icon/Icon"; -import { McpIcon } from '@/components/icons/McpIcon'; - -interface SectionPlaceholderProps { - sectionId: SidebarSection; - variant: 'sidebar' | 'page'; -} - -export const SectionPlaceholder: React.FC = ({ sectionId, variant }) => { - const { t } = useI18n(); - const config = SIDEBAR_SECTION_CONFIG_MAP[sectionId]; - const icon = config.icon; - - if (variant === 'sidebar') { - return ( -
-
- {icon === 'mcp-custom' ? : } -
-

{config.label}

-

- {SIDEBAR_SECTION_DESCRIPTIONS[sectionId]} -

-
- ); - } - - return ( -
-
- {icon === 'mcp-custom' ? : } -
-
-

{config.label}

-

- {SIDEBAR_SECTION_DESCRIPTIONS[sectionId]} -

-
-

{t('settings.common.state.comingSoon')}

-
- ); -}; diff --git a/packages/ui/src/components/sections/mcp/McpSidebar.tsx b/packages/ui/src/components/sections/mcp/McpSidebar.tsx index f7b11c42..04829e03 100644 --- a/packages/ui/src/components/sections/mcp/McpSidebar.tsx +++ b/packages/ui/src/components/sections/mcp/McpSidebar.tsx @@ -384,5 +384,5 @@ export const McpSidebar: React.FC = ({ onItemSelect }) => { }; // Re-export for easy sidebar icon usage -export { McpIcon } from '@/components/icons/McpIcon'; + import { Icon } from "@/components/icon/Icon"; diff --git a/packages/ui/src/components/sections/mcp/mcpImport.ts b/packages/ui/src/components/sections/mcp/mcpImport.ts index afdacd49..cfbc411b 100644 --- a/packages/ui/src/components/sections/mcp/mcpImport.ts +++ b/packages/ui/src/components/sections/mcp/mcpImport.ts @@ -17,7 +17,7 @@ export interface ImportedMcpResult { readonly enabled: boolean; } -export type ImportedMcpError = +type ImportedMcpError = | { readonly ok: false; readonly error: string } | { readonly ok: false; readonly error: string; readonly parsed: unknown }; diff --git a/packages/ui/src/components/sections/mcp/mcpOAuth.ts b/packages/ui/src/components/sections/mcp/mcpOAuth.ts index 1f466e6b..c9b409a9 100644 --- a/packages/ui/src/components/sections/mcp/mcpOAuth.ts +++ b/packages/ui/src/components/sections/mcp/mcpOAuth.ts @@ -53,7 +53,7 @@ export const parseMcpOAuthCallbackStateKey = (params: URLSearchParams): string | return trimmed || null; }; -export const parseMcpOAuthState = (raw: string | null | undefined): { +const parseMcpOAuthState = (raw: string | null | undefined): { name: string; directory: string | null; } | null => { diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index f55f1cf9..aea0f322 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -234,7 +234,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; +type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ diff --git a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx index 7c6f27c6..54d472b7 100644 --- a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx +++ b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx @@ -300,5 +300,3 @@ export const AddPluginDialog: React.FC = ({ ); }; - -export default AddPluginDialog; diff --git a/packages/ui/src/components/sections/plugins/index.ts b/packages/ui/src/components/sections/plugins/index.ts index 2339133e..0c3ad26c 100644 --- a/packages/ui/src/components/sections/plugins/index.ts +++ b/packages/ui/src/components/sections/plugins/index.ts @@ -1,3 +1,2 @@ export { PluginsSidebar } from './PluginsSidebar'; export { PluginsPage } from './PluginsPage'; -export { AddPluginDialog } from './AddPluginDialog'; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx deleted file mode 100644 index 296d7bce..00000000 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout'; -import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem'; -import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { toast } from '@/components/ui'; -import { Icon } from "@/components/icon/Icon"; -import type { DesktopSshInstance } from '@/lib/desktopSsh'; -import { useI18n } from '@/lib/i18n'; - -type RemoteInstancesSidebarProps = { - onItemSelect?: () => void; -}; - -const makeId = (): string => { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`; -}; - -const DIRECT_INSTANCES_ID = '__direct_instances__'; - -const randomPort = (): number => { - return Math.floor(20000 + Math.random() * 30000); -}; - -const isPortInUseError = (error: unknown): boolean => { - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use'); -}; - -const phaseLabelKey = (phase?: string) => { - switch (phase) { - case 'ready': - return 'settings.remoteInstances.sidebar.phase.ready'; - case 'error': - return 'settings.remoteInstances.sidebar.phase.error'; - case 'degraded': - return 'settings.remoteInstances.sidebar.phase.reconnect'; - case 'installing': - return 'settings.remoteInstances.sidebar.phase.installing'; - case 'updating': - return 'settings.remoteInstances.sidebar.phase.updating'; - case 'forwarding': - return 'settings.remoteInstances.sidebar.phase.forwarding'; - case 'server_starting': - return 'settings.remoteInstances.sidebar.phase.starting'; - case 'master_connecting': - return 'settings.remoteInstances.sidebar.phase.connecting'; - default: - return 'settings.remoteInstances.sidebar.phase.idle'; - } -}; - -export const RemoteInstancesSidebar: React.FC = ({ onItemSelect }) => { - const { t } = useI18n(); - const instances = useDesktopSshStore((state) => state.instances); - const statusesById = useDesktopSshStore((state) => state.statusesById); - const isLoading = useDesktopSshStore((state) => state.isLoading); - const load = useDesktopSshStore((state) => state.load); - const loadImports = useDesktopSshStore((state) => state.loadImports); - const createFromCommand = useDesktopSshStore((state) => state.createFromCommand); - const connect = useDesktopSshStore((state) => state.connect); - const disconnect = useDesktopSshStore((state) => state.disconnect); - const retry = useDesktopSshStore((state) => state.retry); - const removeInstance = useDesktopSshStore((state) => state.removeInstance); - const upsertInstance = useDesktopSshStore((state) => state.upsertInstance); - - const selectedId = useUIStore((state) => state.settingsRemoteInstancesSelectedId); - const setSelectedId = useUIStore((state) => state.setSettingsRemoteInstancesSelectedId); - - React.useEffect(() => { - void load(); - void loadImports(); - }, [load, loadImports]); - - React.useEffect(() => { - if (isLoading) return; - if (selectedId === DIRECT_INSTANCES_ID) { - return; - } - if (instances.length === 0) { - if (selectedId !== null) { - setSelectedId(null); - } - return; - } - if (selectedId && instances.some((instance) => instance.id === selectedId)) { - return; - } - setSelectedId(instances[0].id); - }, [instances, isLoading, selectedId, setSelectedId]); - - const handleAdd = React.useCallback(async () => { - const id = makeId(); - try { - await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName')); - setSelectedId(id); - onItemSelect?.(); - } catch (error) { - toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), { - description: error instanceof Error ? error.message : String(error), - }); - } - }, [createFromCommand, onItemSelect, setSelectedId, t]); - - const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => { - try { - await connect(instance.id); - return; - } catch (error) { - if (!isPortInUseError(error)) { - throw error; - } - - const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry')); - if (!allow) { - throw error; - } - - const nextInstance: DesktopSshInstance = { - ...instance, - localForward: { - ...instance.localForward, - preferredLocalPort: randomPort(), - }, - }; - - await upsertInstance(nextInstance); - await connect(nextInstance.id); - toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort')); - } - }, [connect, t, upsertInstance]); - - return ( - -

{t('settings.remoteInstances.sidebar.title')}

-
- {t('settings.remoteInstances.sidebar.total', { count: instances.length })} - -
-
- } - > - { - setSelectedId(DIRECT_INSTANCES_ID); - onItemSelect?.(); - }} - icon={} - /> - {instances.map((instance) => { - const status = statusesById[instance.id]; - const selected = instance.id === selectedId; - const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id; - const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`; - const isReady = status?.phase === 'ready'; - const canRetry = status?.phase === 'error' || status?.phase === 'degraded'; - - return ( - { - setSelectedId(instance.id); - onItemSelect?.(); - }} - actions={[ - { - label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'), - icon: isReady ? 'stop' : 'plug-2', - onClick: () => { - const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance); - void op.catch((error) => { - toast.error( - isReady - ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') - : t('settings.remoteInstances.sidebar.toast.connectFailed'), - { - description: error instanceof Error ? error.message : String(error), - } - ); - }); - }, - }, - { - label: t('settings.remoteInstances.sidebar.actions.retry'), - icon: "refresh", - onClick: () => { - if (!canRetry) return; - void retry(instance.id).catch((error) => { - toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), { - description: error instanceof Error ? error.message : String(error), - }); - }); - }, - }, - { - label: t('settings.remoteInstances.sidebar.actions.remove'), - icon: "delete-bin", - destructive: true, - onClick: () => { - void removeInstance(instance.id).then(() => { - if (selectedId === instance.id) { - const next = instances.find((item) => item.id !== instance.id); - setSelectedId(next?.id || null); - } - }).catch((error) => { - toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), { - description: error instanceof Error ? error.message : String(error), - }); - }); - }, - }, - ]} - /> - ); - })} - - ); -}; diff --git a/packages/ui/src/components/sections/shared/SettingsSection.tsx b/packages/ui/src/components/sections/shared/SettingsSection.tsx deleted file mode 100644 index 6b4e3484..00000000 --- a/packages/ui/src/components/sections/shared/SettingsSection.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; - -interface SettingsSectionProps { - /** Section content */ - children: React.ReactNode; - /** Optional section title */ - title?: string; - /** Optional section description */ - description?: string; - /** If true, adds a top border divider */ - divider?: boolean; - /** Additional className */ - className?: string; -} - -/** - * Standard section wrapper for settings page content. - * Provides consistent spacing and optional divider. - * - * @example - * - * - * - * - * - * - * - * - */ -export const SettingsSection: React.FC = ({ - children, - title, - description, - divider = false, - className, -}) => { - return ( -
- {(title || description) && ( -
- {title && ( -

- {title} -

- )} - {description && ( -

- {description} -

- )} -
- )} - {children} -
- ); -}; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx deleted file mode 100644 index bc4fee1b..00000000 --- a/packages/ui/src/components/sections/shared/SettingsSidebarHeader.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Icon } from "@/components/icon/Icon"; -import { useDeviceInfo } from '@/lib/device'; -import { cn } from '@/lib/utils'; - -interface SettingsSidebarHeaderProps { - /** Total count to display (e.g., "Total 5") */ - count: number; - /** Callback when add button is clicked. If undefined, no add button is shown. */ - onAdd?: () => void; - /** Custom label prefix (default: "Total") */ - label?: string; - /** Aria label for the add button */ - addButtonLabel?: string; -} - -/** - * Standard header for settings sidebars. - * Displays "Total X" on the left and an optional add button on the right. - * - * @example - * setCreateDialogOpen(true)} - * addButtonLabel="Create new agent" - * /> - */ -export const SettingsSidebarHeader: React.FC = ({ - count, - onAdd, - label = 'Total', - addButtonLabel = 'Add new item', -}) => { - const { isMobile } = useDeviceInfo(); - - return ( -
-
- - {label} {count} - - {onAdd && ( - - )} -
-
- ); -}; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx index a1599634..3bdce9c4 100644 --- a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx +++ b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx @@ -10,7 +10,7 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { cn } from '@/lib/utils'; -export interface SettingsSidebarItemAction { +interface SettingsSidebarItemAction { /** Label shown in dropdown menu */ label: string; /** Icon component to show before label */ diff --git a/packages/ui/src/components/sections/shared/index.ts b/packages/ui/src/components/sections/shared/index.ts deleted file mode 100644 index 787a5414..00000000 --- a/packages/ui/src/components/sections/shared/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Shared boilerplate components for settings sections. - * - * These components provide consistent styling and behavior for settings sidebars and pages. - * Use them as building blocks when creating new settings sections. - * - * @example Sidebar usage: - * ```tsx - * import { - * SettingsSidebarLayout, - * SettingsSidebarHeader, - * SettingsSidebarItem, - * } from '@/components/sections/shared'; - * - * export const MySidebar = () => ( - * } - * > - * {items.map(item => ( - * setSelectedId(item.id)} - * actions={[ - * { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true } - * ]} - * /> - * ))} - * - * ); - * ``` - * - * @example Page usage: - * ```tsx - * import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared'; - * - * export const MyPage = () => ( - * - * - * - * - * - * - * - * - * ); - * ``` - */ - -export { SettingsSidebarLayout } from './SettingsSidebarLayout'; -export { SettingsSidebarHeader } from './SettingsSidebarHeader'; -export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem'; -export { SettingsPageLayout } from './SettingsPageLayout'; -export { SettingsSection } from './SettingsSection'; -export { SidebarGroup } from './SidebarGroup'; diff --git a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx deleted file mode 100644 index 96e850ba..00000000 --- a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx +++ /dev/null @@ -1,597 +0,0 @@ -import React from 'react'; -import { toast } from '@/components/ui'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Icon } from "@/components/icon/Icon"; - -import { isVSCodeRuntime } from '@/lib/desktop'; -import type { SkillsCatalogItem } from '@/lib/api/types'; -import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; -import { useSkillsStore } from '@/stores/useSkillsStore'; -import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; -import { useI18n } from '@/lib/i18n'; -import { - SKILL_LOCATION_OPTIONS, - locationPartsFrom, - locationValueFrom, - type SkillLocationValue, -} from '../skillLocations'; - -interface InstallFromRepoDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -type IdentityOption = { id: string; name: string }; - -export const InstallFromRepoDialog: React.FC = ({ open, onOpenChange }) => { - const { t } = useI18n(); - const scanRepo = useSkillsCatalogStore((s) => s.scanRepo); - const installSkills = useSkillsCatalogStore((s) => s.installSkills); - const isScanning = useSkillsCatalogStore((s) => s.isScanning); - const isInstalling = useSkillsCatalogStore((s) => s.isInstalling); - const installedSkills = useSkillsStore((s) => s.skills); - const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); - const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); - - const projects = useProjectsStore((s) => s.projects); - const activeProjectId = useProjectsStore((s) => s.activeProjectId); - const [targetProjectId, setTargetProjectId] = React.useState(null); - - const [source, setSource] = React.useState(''); - const [subpath, setSubpath] = React.useState(''); - const [scope, setScope] = React.useState<'user' | 'project'>('user'); - const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode'); - - const [items, setItems] = React.useState([]); - const [selected, setSelected] = React.useState>({}); - const [search, setSearch] = React.useState(''); - - const [identities, setIdentities] = React.useState([]); - const [gitIdentityId, setGitIdentityId] = React.useState(null); - const scanRequestIdRef = React.useRef(0); - - const invalidateScan = React.useCallback((options?: { clearIdentities?: boolean }) => { - scanRequestIdRef.current += 1; - setItems([]); - setSelected({}); - if (options?.clearIdentities) { - setIdentities([]); - setGitIdentityId(null); - } - }, []); - - const [conflictsOpen, setConflictsOpen] = React.useState(false); - const [conflicts, setConflicts] = React.useState([]); - const [baseInstallRequest, setBaseInstallRequest] = React.useState<{ - source: string; - subpath?: string; - scope: 'user' | 'project'; - targetSource: 'opencode' | 'agents'; - selections: Array<{ skillDir: string }>; - gitIdentityId?: string; - directoryOverride?: string | null; - } | null>(null); - - React.useEffect(() => { - scanRequestIdRef.current += 1; - if (!open) return; - setSource(''); - setSubpath(''); - setScope('user'); - setTargetSource('opencode'); - setTargetProjectId(activeProjectId); - setItems([]); - setSelected({}); - setSearch(''); - setIdentities([]); - setGitIdentityId(null); - void loadDefaultGitIdentityId(); - - setConflictsOpen(false); - - setConflicts([]); - setBaseInstallRequest(null); - }, [open, loadDefaultGitIdentityId, activeProjectId]); - - const resolvedTargetProjectId = React.useMemo(() => { - if (projects.length === 0) { - return null; - } - if (targetProjectId && projects.some((p) => p.id === targetProjectId)) { - return targetProjectId; - } - if (activeProjectId && projects.some((p) => p.id === activeProjectId)) { - return activeProjectId; - } - return projects[0]?.id ?? null; - }, [activeProjectId, projects, targetProjectId]); - - const directoryOverride = React.useMemo(() => { - if (scope !== 'project') { - return null; - } - const id = resolvedTargetProjectId; - if (!id) { - return null; - } - const project = projects.find((p) => p.id === id); - return project?.path ?? null; - }, [projects, resolvedTargetProjectId, scope]); - - const installedByName = React.useMemo(() => { - const map = new Map(); - for (const s of installedSkills) { - map.set(s.name, { scope: s.scope, source: s.source }); - } - return map; - }, [installedSkills]); - - const filteredItems = React.useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return items; - return items.filter((item) => { - const name = item.skillName.toLowerCase(); - const desc = (item.description || '').toLowerCase(); - const fm = (item.frontmatterName || '').toLowerCase(); - return name.includes(q) || desc.includes(q) || fm.includes(q); - }); - }, [items, search]); - - const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]); - - const toggleAll = (value: boolean) => { - const next: Record = {}; - for (const item of items) { - if (!item.installable) continue; - next[item.skillDir] = value; - } - setSelected(next); - }; - - const locationLabelText = React.useCallback((value: SkillLocationValue) => { - switch (value) { - case 'project-opencode': - return t('settings.skills.location.option.projectOpencode.label'); - case 'user-agents': - return t('settings.skills.location.option.userAgents.label'); - case 'project-agents': - return t('settings.skills.location.option.projectAgents.label'); - default: - return t('settings.skills.location.option.userOpencode.label'); - } - }, [t]); - - const locationDescriptionText = React.useCallback((value: SkillLocationValue) => { - switch (value) { - case 'project-opencode': - return t('settings.skills.location.option.projectOpencode.description'); - case 'user-agents': - return t('settings.skills.location.option.userAgents.description'); - case 'project-agents': - return t('settings.skills.location.option.projectAgents.description'); - default: - return t('settings.skills.location.option.userOpencode.description'); - } - }, [t]); - - const handleScan = async () => { - const trimmed = source.trim(); - if (!trimmed) { - toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired')); - return; - } - - setItems([]); - setSelected({}); - const requestId = scanRequestIdRef.current + 1; - scanRequestIdRef.current = requestId; - - const result = await scanRepo({ - source: trimmed, - subpath: subpath.trim() || undefined, - gitIdentityId: gitIdentityId || undefined, - }); - - if (scanRequestIdRef.current !== requestId) { - return; - } - - if (!result.ok) { - if (result.error?.kind === 'authRequired') { - if (isVSCodeRuntime()) { - toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode')); - return; - } - - const ids = (result.error.identities || []) as IdentityOption[]; - setIdentities(ids); - if (!gitIdentityId && ids.length > 0) { - const preferred = - defaultGitIdentityId && - defaultGitIdentityId !== 'global' && - ids.some((i) => i.id === defaultGitIdentityId) - ? defaultGitIdentityId - : ids[0].id; - setGitIdentityId(preferred); - } - toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan')); - return; - } - - toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed')); - return; - } - - const nextItems = result.items || []; - setItems(nextItems); - - // Auto-select all installable items when scanning returns a small set. - const nextSelected: Record = {}; - for (const item of nextItems) { - if (item.installable) { - nextSelected[item.skillDir] = true; - } - } - setSelected(nextSelected); - - setIdentities([]); - toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length })); - }; - - const doInstall = async (opts: { conflictDecisions?: Record }) => { - if (selectedDirs.length === 0) { - toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne')); - return; - } - - const request = { - source: source.trim(), - subpath: subpath.trim() || undefined, - scope, - targetSource, - selections: selectedDirs.map((dir) => ({ skillDir: dir })), - gitIdentityId: gitIdentityId || undefined, - directoryOverride, - }; - - const result = await installSkills( - { - source: request.source, - subpath: request.subpath, - scope: request.scope, - targetSource: request.targetSource, - selections: request.selections, - gitIdentityId: request.gitIdentityId, - conflictPolicy: 'prompt', - conflictDecisions: opts.conflictDecisions, - }, - { directory: request.directoryOverride ?? null } - ); - - if (result.ok) { - const installedCount = result.installed?.length || 0; - toast.success( - installedCount > 0 - ? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount }) - : t('settings.skills.catalog.installFromRepo.toast.installCompleted') - ); - onOpenChange(false); - return; - } - - if (result.error?.kind === 'conflicts') { - setBaseInstallRequest(request); - setConflicts(result.error.conflicts); - setConflictsOpen(true); - return; - } - - if (result.error?.kind === 'authRequired') { - if (isVSCodeRuntime()) { - toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode')); - return; - } - const ids = (result.error.identities || []) as IdentityOption[]; - setIdentities(ids); - if (!gitIdentityId && ids.length > 0) { - const preferred = - defaultGitIdentityId && - defaultGitIdentityId !== 'global' && - ids.some((i) => i.id === defaultGitIdentityId) - ? defaultGitIdentityId - : ids[0].id; - setGitIdentityId(preferred); - } - toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall')); - return; - } - - toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed')); - }; - - return ( - <> - - - - {t('settings.skills.catalog.installFromRepo.title')} - - {t('settings.skills.catalog.installFromRepo.descriptionPrefix')} - {' '} - SKILL.md - {t('settings.skills.catalog.installFromRepo.descriptionSuffix')} - - - -
-
- -
- { - setSource(e.target.value); - invalidateScan({ clearIdentities: true }); - }} - placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')} - className="text-foreground placeholder:text-muted-foreground" - /> - -
-

- {t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')} - {' '} - owner/repo/skills - {'.'} -

-
- -
-
- - { - setSubpath(e.target.value); - invalidateScan({ clearIdentities: true }); - }} - placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')} - className="text-foreground placeholder:text-muted-foreground" - /> -
- -
- - -
-
- - {scope === 'project' && ( -
- - {projects.length === 0 ? ( -

{t('settings.skills.catalog.shared.field.noProjects')}

- ) : ( - - )} -
- )} - - {identities.length > 0 && !isVSCodeRuntime() ? ( -
-
{t('settings.skills.catalog.shared.auth.title')}
-
- {t('settings.skills.catalog.installFromRepo.authDescription')} -
-
- -
-
- {t('settings.skills.catalog.shared.auth.footerHintArrow')} -
-
- ) : null} -
- -
- {items.length === 0 ? ( -
-
-

{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}

-

{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}

-
-
- ) : ( -
-
- setSearch(e.target.value)} - placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')} - className="max-w-sm" - /> -
- - -
-
- - - {filteredItems.map((item) => { - const installed = installedByName.get(item.skillName); - const checked = Boolean(selected[item.skillDir]); - const disabled = !item.installable; - - return ( - - ); - })} - - -
- {t('settings.skills.catalog.installFromRepo.selectedCount', { - selected: selectedDirs.length, - total: items.filter((i) => i.installable).length, - })} -
-
- )} -
- - - - - -
-
- - { - if (!baseInstallRequest) { - setConflictsOpen(false); - return; - } - void doInstall({ conflictDecisions: decisions }); - setConflictsOpen(false); - }} - /> - - ); -}; diff --git a/packages/ui/src/components/sections/skills/index.ts b/packages/ui/src/components/sections/skills/index.ts deleted file mode 100644 index 29c6e3f0..00000000 --- a/packages/ui/src/components/sections/skills/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SkillsSidebar } from './SkillsSidebar'; -export { SkillsPage } from './SkillsPage'; diff --git a/packages/ui/src/components/sections/skills/skillLocations.ts b/packages/ui/src/components/sections/skills/skillLocations.ts index ba7219ad..a0009a20 100644 --- a/packages/ui/src/components/sections/skills/skillLocations.ts +++ b/packages/ui/src/components/sections/skills/skillLocations.ts @@ -57,10 +57,3 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop } return { scope: match.scope, source: match.source }; } - -export function locationLabel(scope: SkillScope, source: SkillSource): string { - if (scope === 'user' && source === 'claude') return 'User / Claude'; - if (scope === 'project' && source === 'claude') return 'Project / Claude'; - const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source); - return match?.label || `${scope} / ${source}`; -} diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx deleted file mode 100644 index ba9b0684..00000000 --- a/packages/ui/src/components/session/BranchPickerDialog.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import * as React from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { toast } from '@/components/ui'; -import { Icon } from "@/components/icon/Icon"; -import { cn } from '@/lib/utils'; -import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi'; -import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types'; -import type { WorktreeMetadata } from '@/types/worktree'; -import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate'; -import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; -import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; -import { sessionEvents } from '@/lib/sessionEvents'; -import { useSessions } from '@/sync/sync-context'; -import { useI18n } from '@/lib/i18n'; - -export interface BranchPickerProject { - id: string; - path: string; - normalizedPath: string; - label?: string; -} - -interface BranchPickerDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - project: BranchPickerProject | null; -} - -const displayProjectName = (project: BranchPickerProject): string => - project.label || project.normalizedPath.split('/').pop() || project.normalizedPath; - -const normalizeBranchName = (value: string | null | undefined): string => { - return String(value || '') - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^heads\//, '') - .replace(/^remotes\//, ''); -}; - -const normalizePath = (value: string | null | undefined): string => { - const raw = String(value || '').trim().replace(/\\/g, '/'); - if (!raw) { - return ''; - } - if (raw === '/') { - return '/'; - } - return raw.length > 1 ? raw.replace(/\/+$/, '') : raw; -}; - -export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) { - const { t } = useI18n(); - const sessions = useSessions(); - const [searchQuery, setSearchQuery] = React.useState(''); - const [branches, setBranches] = React.useState(null); - const [worktrees, setWorktrees] = React.useState([]); - const [rootBranchName, setRootBranchName] = React.useState(null); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); - - const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState(null); - const [deletingBranch, setDeletingBranch] = React.useState(null); - const [confirmingDelete, setConfirmingDelete] = React.useState(null); - const [forceDeleteBranch, setForceDeleteBranch] = React.useState(null); - const [editingBranch, setEditingBranch] = React.useState(null); - const [editValue, setEditValue] = React.useState(''); - const [renamingBranchKey, setRenamingBranchKey] = React.useState(null); - - const refresh = React.useCallback(async () => { - if (!project) return; - setLoading(true); - setError(null); - try { - const [b, w, rootBranch] = await Promise.all([ - getGitBranches(project.path), - git.worktree.list(project.path), - getRootBranch(project.path).catch(() => null), - ]); - setBranches(b); - setWorktrees(w); - setRootBranchName(rootBranch); - } catch (err) { - setError(err instanceof Error ? err.message : t('branchPickerDialog.error.failedToLoad')); - setBranches(null); - setWorktrees([]); - setRootBranchName(null); - } finally { - setLoading(false); - } - }, [project, t]); - - React.useEffect(() => { - if (!open) { - setSearchQuery(''); - setConfirmingDelete(null); - setForceDeleteBranch(null); - setEditingBranch(null); - setEditValue(''); - setRenamingBranchKey(null); - setCreatingWorktreeBranch(null); - return; - } - void refresh(); - }, [open, refresh]); - - const filterBranches = (list: string[], query: string): string[] => { - if (!query.trim()) return list; - const lower = query.toLowerCase(); - return list.filter((b) => b.toLowerCase().includes(lower)); - }; - - const beginRename = React.useCallback((branchName: string) => { - setEditingBranch(branchName); - setEditValue(branchName); - }, []); - - const cancelRename = React.useCallback(() => { - setEditingBranch(null); - setEditValue(''); - setRenamingBranchKey(null); - }, []); - - const cancelDelete = React.useCallback(() => { - setConfirmingDelete(null); - setForceDeleteBranch(null); - }, []); - - const commitRename = React.useCallback(async (oldName: string) => { - if (!project) return; - const newName = editValue.trim(); - if (!newName || newName === oldName) { - cancelRename(); - return; - } - - setRenamingBranchKey(oldName); - try { - const result = await renameBranch(project.path, oldName, newName); - if (!result?.success) { - throw new Error(t('branchPickerDialog.error.renameRejected')); - } - await refresh(); - cancelRename(); - toast.success(t('branchPickerDialog.toast.branchRenamed'), { description: `${oldName} -> ${newName}` }); - } catch (err) { - toast.error(t('branchPickerDialog.toast.failedToRenameBranch'), { - description: err instanceof Error ? err.message : t('branchPickerDialog.error.renameFailed'), - }); - setRenamingBranchKey(null); - } - }, [project, editValue, refresh, cancelRename, t]); - - const handleDeleteBranch = React.useCallback(async (branchName: string) => { - if (!project) return; - setDeletingBranch(branchName); - try { - const force = forceDeleteBranch === branchName; - const result = await deleteGitBranch(project.path, { branch: branchName, force }); - if (!result?.success) { - throw new Error(t('branchPickerDialog.error.deleteRejected')); - } - await refresh(); - toast.success(t('branchPickerDialog.toast.branchDeleted'), { description: branchName }); - setConfirmingDelete(null); - setForceDeleteBranch(null); - } catch (err) { - const message = err instanceof Error ? err.message : t('branchPickerDialog.error.deleteFailed'); - // If branch isn't merged, prompt for force delete on next confirm. - if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) { - setForceDeleteBranch(branchName); - toast.error(t('branchPickerDialog.toast.branchNotMerged'), { - description: t('branchPickerDialog.toast.confirmAgainToForceDelete'), - }); - } else { - toast.error(t('branchPickerDialog.toast.failedToDeleteBranch'), { description: message }); - } - } finally { - setDeletingBranch(null); - } - }, [project, refresh, forceDeleteBranch, t]); - - const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => { - if (!project) { - return; - } - - setCreatingWorktreeBranch(branchName); - try { - const setupCommands = await getWorktreeSetupCommands({ - id: project.id, - path: project.path, - }); - await createWorktreeWithDefaults( - { - id: project.id, - path: project.path, - }, - { - preferredName: branchName, - mode: 'existing', - existingBranch: branchName, - branchName, - worktreeName: branchName, - setupCommands, - } - ); - await refresh(); - toast.success(t('branchPickerDialog.toast.worktreeCreated'), { description: branchName }); - } catch (err) { - toast.error(t('branchPickerDialog.toast.failedToCreateWorktree'), { - description: err instanceof Error ? err.message : t('branchPickerDialog.error.createWorktreeFailed'), - }); - } finally { - setCreatingWorktreeBranch(null); - } - }, [project, refresh, t]); - - const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => { - if (!project || !worktree) { - return; - } - - const normalizedWorktreePath = normalizePath(worktree.path); - const directSessions = sessions.filter((session) => { - const sessionPath = normalizePath(session.directory ?? null); - return Boolean(sessionPath) && sessionPath === normalizedWorktreePath; - }); - const directSessionIds = new Set(directSessions.map((session) => session.id)); - - const findSubsessions = (parentIds: Set): typeof sessions => { - const subsessions = sessions.filter((session) => { - const parentID = (session as { parentID?: string | null }).parentID; - if (!parentID) { - return false; - } - return parentIds.has(parentID); - }); - if (subsessions.length === 0) { - return []; - } - const subsessionIds = new Set(subsessions.map((session) => session.id)); - return [...subsessions, ...findSubsessions(subsessionIds)]; - }; - - const allSubsessions = findSubsessions(directSessionIds); - const seenIds = new Set(); - const allSessions = [...directSessions, ...allSubsessions].filter((session) => { - if (seenIds.has(session.id)) { - return false; - } - seenIds.add(session.id); - return true; - }); - - const normalizedBranch = normalizeBranchName(worktree.branch); - const worktreeMetadata: WorktreeMetadata = { - source: 'sdk', - name: worktree.name, - path: worktree.path, - projectDirectory: project.path, - branch: normalizedBranch, - label: normalizedBranch || worktree.name, - }; - - sessionEvents.requestDelete({ - sessions: allSessions, - mode: 'worktree', - worktree: worktreeMetadata, - }); - }, [project, sessions]); - - const worktreeByBranch = new Map(); - for (const worktree of worktrees) { - const branchName = normalizeBranchName(worktree.branch); - if (branchName && !worktreeByBranch.has(branchName)) { - worktreeByBranch.set(branchName, worktree); - } - } - - const normalizedRootBranch = normalizeBranchName(rootBranchName); - const allBranches = branches?.all || []; - const filteredBranches = filterBranches(allBranches, searchQuery); - const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/')); - - return ( - - - - - - {t('branchPickerDialog.title')} - - - {project ? t('branchPickerDialog.description.localBranchesForProject', { project: displayProjectName(project) }) : t('branchPickerDialog.description.selectProject')} - - - -
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
-
- {!project ? ( -
{t('branchPickerDialog.state.noProjectSelected')}
- ) : loading ? ( -
{t('branchPickerDialog.state.loadingBranches')}
- ) : error ? ( -
{error}
- ) : localBranches.length === 0 ? ( -
- {searchQuery ? t('branchPickerDialog.state.noMatchingBranches') : t('branchPickerDialog.state.noBranchesFound')} -
- ) : ( - localBranches.map((branchName) => { - const details = branches?.branches[branchName]; - const normalizedBranchName = normalizeBranchName(branchName); - const isCurrent = Boolean(details?.current); - const isDeleting = deletingBranch === branchName; - const isRenaming = renamingBranchKey === branchName; - const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null; - const hasAttachedWorktree = Boolean(attachedWorktree); - const isProjectRootBranch = Boolean( - normalizedBranchName && - normalizedRootBranch && - normalizedBranchName === normalizedRootBranch - ); - const isEditing = editingBranch === branchName; - const isConfirming = confirmingDelete === branchName; - const isForceDelete = forceDeleteBranch === branchName; - const isCreatingWorktree = creatingWorktreeBranch === branchName; - - const disableCreateWorktree = Boolean( - hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing - ); - const disableDelete = Boolean( - isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch - ); - const disableRename = Boolean( - isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch - ); - const disableWorktreeDelete = Boolean( - isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree - ); - - return ( -
- - -
-
- {isEditing ? ( -
{ - event.preventDefault(); - void commitRename(branchName); - }} - > - setEditValue(event.target.value)} - className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground" - autoFocus - placeholder={t('branchPickerDialog.search.renameBranchPlaceholder')} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelRename(); - } - if (event.key === 'Enter') { - event.preventDefault(); - void commitRename(branchName); - } - }} - /> -
- ) : ( - - {branchName} - - )} - - {isCurrent && ( - - {t('branchPickerDialog.badge.head')} - - )} - - {hasAttachedWorktree && !isEditing && ( - - {t('branchPickerDialog.badge.worktree')} - - )} -
- -
- {details?.commit ? ( - {details.commit.slice(0, 7)} - ) : null} - {typeof details?.ahead === 'number' && details.ahead > 0 ? ( - ↑{details.ahead} - ) : null} - {typeof details?.behind === 'number' && details.behind > 0 ? ( - ↓{details.behind} - ) : null} -
-
- - {!isEditing && !isConfirming ? ( -
- - - - - - {hasAttachedWorktree ? t('branchPickerDialog.tooltip.worktreeAlreadyExists') : t('branchPickerDialog.tooltip.createWorktree')} - - - - - - - - - {isProjectRootBranch ? t('branchPickerDialog.tooltip.renameDisabledForRoot') : t('branchPickerDialog.tooltip.rename')} - - - - - - - - - {hasAttachedWorktree - ? isProjectRootBranch - ? t('branchPickerDialog.tooltip.deleteWorktreeRootProtected') - : t('branchPickerDialog.tooltip.deleteWorktree') - : isCurrent - ? t('branchPickerDialog.tooltip.deleteCurrentBranch') - : isProjectRootBranch - ? t('branchPickerDialog.tooltip.deleteDisabledForRoot') - : t('branchPickerDialog.tooltip.delete')} - - -
- ) : null} - - {isEditing ? ( -
- - -
- ) : null} - - {!isEditing && isConfirming && !hasAttachedWorktree ? ( -
- - {isForceDelete ? t('branchPickerDialog.actions.forceDeletePrompt') : t('branchPickerDialog.actions.deletePrompt')} - - - -
- ) : null} -
- ); - }) - )} -
-
-
-
- ); -} diff --git a/packages/ui/src/components/session/DirectoryAutocomplete.tsx b/packages/ui/src/components/session/DirectoryAutocomplete.tsx deleted file mode 100644 index 348f09a0..00000000 --- a/packages/ui/src/components/session/DirectoryAutocomplete.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; -import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client'; -import { useDebouncedValue } from '@/hooks/useDebouncedValue'; -import { Icon } from "@/components/icon/Icon"; - -interface DirectoryAutocompleteProps { - inputValue: string; - homeDirectory: string | null; - onSelectSuggestion: (path: string) => void; - visible: boolean; - onClose: () => void; - showHidden: boolean; -} - -export interface DirectoryAutocompleteHandle { - handleKeyDown: (e: React.KeyboardEvent) => boolean; -} - -export const DirectoryAutocomplete = React.forwardRef(({ - inputValue, - homeDirectory, - onSelectSuggestion, - visible, - onClose, - showHidden, -}, ref) => { - const [suggestions, setSuggestions] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [selectedIndex, setSelectedIndex] = React.useState(0); - const containerRef = React.useRef(null); - const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); - - // Fuzzy matching score - returns null if no match, higher score = better match - const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { - const q = query.trim().toLowerCase(); - if (!q) { - return 0; - } - - const c = candidate.toLowerCase(); - 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; // Character not found - no match - } - - const gap = idx - lastIndex - 1; - if (gap === 0) { - consecutive += 1; - } else { - consecutive = 0; - } - - score += 10; // Base score per matched char - score += Math.max(0, 18 - idx); // Bonus for early matches - score -= Math.max(0, gap); // Penalty for gaps - - // Bonus for match at start or after separator - 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; - } - - score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher - return score; - }, []); - - // Expand ~ to home directory - const expandPath = React.useCallback((path: string): string => { - if (path.startsWith('~') && homeDirectory) { - return path.replace(/^~/, homeDirectory); - } - return path; - }, [homeDirectory]); - - // Get the directory part of the path for listing - const getParentDir = React.useCallback((path: string): string => { - const expanded = expandPath(path); - // If ends with /, list that directory - if (expanded.endsWith('/')) { - return expanded; - } - // Otherwise, get parent directory - const lastSlash = expanded.lastIndexOf('/'); - if (lastSlash === -1) return ''; - if (lastSlash === 0) return '/'; - return expanded.substring(0, lastSlash + 1); - }, [expandPath]); - - // Get the partial name being typed (for filtering) - const getPartialName = React.useCallback((path: string): string => { - const expanded = expandPath(path); - if (expanded.endsWith('/')) return ''; - const lastSlash = expanded.lastIndexOf('/'); - if (lastSlash === -1) return expanded; - return expanded.substring(lastSlash + 1); - }, [expandPath]); - - const debouncedInputValue = useDebouncedValue(inputValue, 150); - - // Fetch directory suggestions - React.useEffect(() => { - if (!visible || !debouncedInputValue) { - setSuggestions([]); - return; - } - - const parentDir = getParentDir(debouncedInputValue); - const partialName = getPartialName(debouncedInputValue).toLowerCase(); - - if (!parentDir) { - setSuggestions([]); - return; - } - - let cancelled = false; - setLoading(true); - - opencodeClient.listLocalDirectory(parentDir) - .then((entries) => { - if (cancelled) return; - - // Filter to directories only, respect hidden setting - const directories = entries.filter((entry) => { - if (!entry.isDirectory) return false; - if (!showHidden && entry.name.startsWith('.')) return false; - return true; - }); - - // Apply fuzzy matching and sort by score - const scored = partialName - ? directories - .map((entry) => { - const score = fuzzyScore(partialName, entry.name); - return score !== null ? { entry, score } : null; - }) - .filter((item): item is { entry: FilesystemEntry; score: number } => item !== null) - .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name)) - .map((item) => item.entry) - : directories.sort((a, b) => a.name.localeCompare(b.name)); - - setSuggestions(scored.slice(0, 10)); // Limit suggestions - setSelectedIndex(0); - }) - .catch(() => { - if (!cancelled) { - setSuggestions([]); - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]); - - // Scroll selected item into view - React.useEffect(() => { - itemRefs.current[selectedIndex]?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest' - }); - }, [selectedIndex]); - - // Handle outside click - React.useEffect(() => { - if (!visible) return; - - const handlePointerDown = (event: MouseEvent | TouchEvent) => { - const target = event.target as Node | null; - if (!target || !containerRef.current) return; - if (containerRef.current.contains(target)) return; - onClose(); - }; - - document.addEventListener('pointerdown', handlePointerDown, true); - return () => { - document.removeEventListener('pointerdown', handlePointerDown, true); - }; - }, [visible, onClose]); - - const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => { - // Append the selected directory name to current path, with trailing slash - const path = entry.path.endsWith('/') ? entry.path : entry.path + '/'; - onSelectSuggestion(path); - }, [onSelectSuggestion]); - - // Expose key handler to parent - React.useImperativeHandle(ref, () => ({ - handleKeyDown: (e: React.KeyboardEvent): boolean => { - if (!visible || suggestions.length === 0) { - return false; - } - - const total = suggestions.length; - - if (e.key === 'Tab') { - e.preventDefault(); - if (e.shiftKey) { - // Shift+Tab: previous suggestion - setSelectedIndex((prev) => (prev - 1 + total) % total); - } else { - // Tab: next suggestion or select if only one - if (total === 1) { - const selected = suggestions[0]; - if (selected) { - handleSelectSuggestion(selected); - } - } else { - setSelectedIndex((prev) => (prev + 1) % total); - } - } - return true; - } - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % total); - return true; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + total) % total); - return true; - } - - if (e.key === 'Enter') { - e.preventDefault(); - // Select current item and close autocomplete - const safeIndex = ((selectedIndex % total) + total) % total; - const selected = suggestions[safeIndex]; - if (selected) { - handleSelectSuggestion(selected); - } - onClose(); - return true; // Consume the event, don't let parent confirm yet - } - - if (e.key === 'Escape') { - e.preventDefault(); - onClose(); - return true; - } - - return false; - } - }), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]); - - if (!visible || (suggestions.length === 0 && !loading)) { - return null; - } - - return ( -
- {loading ? ( -
- -
- ) : ( -
- {suggestions.map((entry, index) => { - const isSelected = selectedIndex === index; - return ( -
{ itemRefs.current[index] = el; }} - className={cn( - "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label", - isSelected && "bg-interactive-selection" - )} - onClick={() => { handleSelectSuggestion(entry); onClose(); }} - onMouseEnter={() => setSelectedIndex(index)} - > - - {entry.name} -
- ); - })} -
- )} -
- Tab cycle • ↑↓ navigate • Enter select -
-
- ); -}); - -DirectoryAutocomplete.displayName = 'DirectoryAutocomplete'; diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx deleted file mode 100644 index 276e17c7..00000000 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ /dev/null @@ -1,1335 +0,0 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from '@/components/ui/dropdown-menu'; -import { cn, formatPathForDisplay } from '@/lib/utils'; -import { opencodeClient } from '@/lib/opencode/client'; -import { useDeviceInfo } from '@/lib/device'; -import type { DesktopSettings } from '@/lib/desktop'; -import { updateDesktopSettings } from '@/lib/persistence'; -import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { Icon } from "@/components/icon/Icon"; -import { useI18n } from '@/lib/i18n'; -import { runtimeFetch } from '@/lib/runtime-fetch'; - -interface DirectoryItem { - name: string; - path: string; - isDirectory: boolean; - children?: DirectoryItem[]; - isExpanded?: boolean; -} - -interface DirectoryTreeProps { - currentPath: string; - onSelectPath: (path: string) => void; - triggerClassName?: string; - variant?: 'dropdown' | 'inline'; - className?: string; - selectionBehavior?: 'immediate' | 'deferred'; - onDoubleClickPath?: (path: string) => void; - showHidden?: boolean; - rootDirectory?: string | null; - isRootReady?: boolean; - /** Always show action icons (add, pin) instead of only on hover */ - alwaysShowActions?: boolean; - disabledPaths?: Iterable; -} - -const areStringSetsEqual = (left: Set, right: Set) => { - if (left.size !== right.size) { - return false; - } - for (const value of left) { - if (!right.has(value)) { - return false; - } - } - return true; -}; - -export const DirectoryTree: React.FC = ({ - currentPath, - onSelectPath, - triggerClassName, - variant = 'dropdown', - className, - selectionBehavior = 'immediate', - onDoubleClickPath, - showHidden = false, - rootDirectory = null, - isRootReady, - alwaysShowActions = false, - disabledPaths, -}) => { - const { t } = useI18n(); - const { isMobile, isTablet } = useDeviceInfo(); - const showHoverActions = alwaysShowActions || isTablet; - const [directories, setDirectories] = React.useState([]); - const [expandedPaths, setExpandedPaths] = React.useState>(new Set()); - const [isLoading, setIsLoading] = React.useState(true); - const [isOpen, setIsOpen] = React.useState(false); - const [homeDirectory, setHomeDirectory] = React.useState(''); - const [pinnedPaths, setPinnedPaths] = React.useState>(new Set()); - const [creatingInPath, setCreatingInPath] = React.useState(null); - const [newDirName, setNewDirName] = React.useState(''); - const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true); - const [pinnedExpandedPaths, setPinnedExpandedPaths] = React.useState>(new Set()); - const [pinnedItemChildren, setPinnedItemChildren] = React.useState>(new Map()); - const inputRef = React.useRef(null); - const { requestAccess, startAccessing, isDesktop } = useFileSystemAccess(); - const previousShowHidden = React.useRef(showHidden); - - const stripTrailingSlashes = React.useCallback((value: string | null | undefined) => { - if (!value) { - return value; - } - if (value === '/' || value.length === 0) { - return '/'; - } - let trimmed = value; - while (trimmed.length > 1 && trimmed.endsWith('/')) { - trimmed = trimmed.slice(0, -1); - } - return trimmed.length === 0 ? '/' : trimmed; - }, []); - - const normalizedDisabledPaths = React.useMemo(() => { - const normalized = new Set(); - for (const path of disabledPaths ?? []) { - const value = stripTrailingSlashes(path.replace(/\\/g, '/')); - if (value) normalized.add(value.toLowerCase()); - } - return normalized; - }, [disabledPaths, stripTrailingSlashes]); - - const isPathDisabled = React.useCallback((path: string) => { - const normalized = stripTrailingSlashes(path.replace(/\\/g, '/')); - return normalized ? normalizedDisabledPaths.has(normalized.toLowerCase()) : false; - }, [normalizedDisabledPaths, stripTrailingSlashes]); - - const normalizedHomeDirectory = React.useMemo(() => { - if (!homeDirectory) { - return null; - } - const normalized = homeDirectory.replace(/\\/g, '/'); - return stripTrailingSlashes(normalized) as string; - }, [homeDirectory, stripTrailingSlashes]); - - const effectiveRoot = React.useMemo(() => { - if (typeof rootDirectory === 'string' && rootDirectory.length > 0) { - const normalized = rootDirectory.replace(/\\/g, '/'); - const stripped = stripTrailingSlashes(normalized); - if (stripped && stripped !== '/') { - return stripped as string; - } - } - if (normalizedHomeDirectory && normalizedHomeDirectory !== '/') { - return normalizedHomeDirectory; - } - return null; - }, [rootDirectory, normalizedHomeDirectory, stripTrailingSlashes]); - - const rootReady = React.useMemo(() => { - if (typeof isRootReady === 'boolean') { - return Boolean(isRootReady && effectiveRoot); - } - return Boolean(effectiveRoot); - }, [isRootReady, effectiveRoot]); - - React.useEffect(() => { - if (!rootReady) { - setIsLoading(true); - setDirectories([]); - } - }, [rootReady]); - - const isPathWithinHome = React.useCallback( - (targetPath: string | null | undefined): boolean => { - if (!targetPath) { - return false; - } - if (!rootReady || !effectiveRoot) { - return false; - } - const normalizedTargetRaw = targetPath.replace(/\\/g, '/'); - const normalizedTarget = - (stripTrailingSlashes(normalizedTargetRaw) as string) ?? normalizedTargetRaw; - if (normalizedTarget === effectiveRoot) { - return true; - } - const prefix = `${effectiveRoot}/`; - return normalizedTarget.startsWith(prefix); - }, - [rootReady, effectiveRoot, stripTrailingSlashes] - ); - - const handleDirectorySelect = async (path: string) => { - if (!rootReady) { - return; - } - - if (selectionBehavior === 'deferred') { - onSelectPath(path); - return; - } - - if (isDesktop) { - - const accessResult = await requestAccess(path); - if (accessResult.success && accessResult.path) { - - await startAccessing(accessResult.path); - onSelectPath(accessResult.path); - } else { - console.error('Failed to get directory access:', accessResult.error); - - onSelectPath(path); - } - } else { - onSelectPath(path); - } - }; - - React.useEffect(() => { - let cancelled = false; - - const applyRootDirectory = (candidate: string | null | undefined) => { - if (!candidate) { - return false; - } - const normalized = stripTrailingSlashes(candidate.replace(/\\/g, '/')); - if (!normalized || normalized === '/') { - return false; - } - setHomeDirectory(typeof normalized === 'string' ? normalized : candidate.replace(/\\/g, '/')); - return true; - }; - - const appliedInitialRoot = rootDirectory ? applyRootDirectory(rootDirectory) : false; - - const resolveHomeDirectory = async () => { - try { - const fsHome = await opencodeClient.getFilesystemHome(); - if (!cancelled && applyRootDirectory(fsHome)) { - return; - } - } catch (error) { - console.warn('Failed to resolve filesystem home directory:', error); - } - - try { - const info = await opencodeClient.getSystemInfo(); - if (!cancelled && applyRootDirectory(info?.homeDirectory)) { - return; - } - } catch (error) { - console.warn('Failed to resolve home directory from system info:', error); - } - }; - - if (!appliedInitialRoot) { - resolveHomeDirectory(); - } - - return () => { - cancelled = true; - }; - }, [rootDirectory, stripTrailingSlashes]); - - React.useEffect(() => { - let cancelled = false; - - const applyPinned = (paths: string[]) => { - if (cancelled) { - return; - } - const normalized = paths - .filter((path): path is string => typeof path === 'string' && path.length > 0) - .map((path) => { - const normalizedPath = path.replace(/\\/g, '/'); - return (stripTrailingSlashes(normalizedPath) as string) ?? normalizedPath; - }); - setPinnedPaths((prev) => { - const next = new Set(normalized); - return areStringSetsEqual(prev, next) ? prev : next; - }); - }; - - const loadFromLocalStorage = () => { - try { - const raw = localStorage.getItem('pinnedDirectories'); - if (!raw) { - return; - } - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - applyPinned(parsed); - } - } catch (error) { - console.warn('Failed to load pinned directories from local storage:', error); - } - }; - - const loadPinnedDirectories = async () => { - try { - let pinned: string[] = []; - - const response = await runtimeFetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = await response.json(); - pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : []; - } - - if (cancelled) { - return; - } - - applyPinned(pinned); - } catch (error) { - console.warn('Failed to load pinned directories:', error); - } - }; - - loadFromLocalStorage(); - - const handleSettingsSynced = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (detail && Array.isArray(detail.pinnedDirectories)) { - applyPinned(detail.pinnedDirectories); - } - }; - window.addEventListener('openchamber:settings-synced', handleSettingsSynced); - - void loadPinnedDirectories(); - - return () => { - cancelled = true; - window.removeEventListener('openchamber:settings-synced', handleSettingsSynced); - }; - }, [stripTrailingSlashes]); - - const isInitialPinnedSync = React.useRef(true); - - React.useEffect(() => { - if (isInitialPinnedSync.current) { - isInitialPinnedSync.current = false; - return; - } - - const payload = { - pinnedDirectories: Array.from(pinnedPaths), - }; - - void updateDesktopSettings(payload); - }, [pinnedPaths]); - - React.useEffect(() => { - if (!effectiveRoot) { - return; - } - setPinnedPaths((prev) => { - const filtered = Array.from(prev) - .map((path) => (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path) - .filter((path) => isPathWithinHome(path)); - const next = new Set(filtered); - return areStringSetsEqual(prev, next) ? prev : next; - }); - }, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]); - - // Clean up pinned expansion state when pinned paths are removed (e.g., unpinned or filtered out) - React.useEffect(() => { - const pinnedRoots = Array.from(pinnedPaths); - const isWithinPinnedRoot = (path: string) => pinnedRoots.some((root) => ( - path === root || path.startsWith(`${root}/`) - )); - - setPinnedExpandedPaths(prev => { - const next = new Set(prev); - for (const path of prev) { - if (!isWithinPinnedRoot(path)) { - next.delete(path); - } - } - return next; - }); - setPinnedItemChildren(prev => { - const next = new Map(prev); - for (const [path] of prev) { - if (!isWithinPinnedRoot(path)) { - next.delete(path); - } - } - return next; - }); - }, [pinnedPaths]); - - // Reload directories when showHidden changes, but keep expanded state - React.useEffect(() => { - if (previousShowHidden.current !== showHidden) { - previousShowHidden.current = showHidden; - // Silently reload without clearing state - loadInitialDirectories will be called - // via its dependency on loadDirectory which depends on showHidden - } - }, [showHidden]); - - const togglePin = (path: string) => { - setPinnedPaths(prev => { - if (!isPathWithinHome(path)) { - return prev; - } - const normalizedPath = - (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path.replace(/\\/g, '/'); - const newSet = new Set(prev); - if (newSet.has(normalizedPath)) { - newSet.delete(normalizedPath); - } else { - newSet.add(normalizedPath); - } - return newSet; - }); - }; - - const pinnedDirectories = React.useMemo(() => { - return Array.from(pinnedPaths) - .map((rawPath) => { - const normalizedPath = stripTrailingSlashes(rawPath.replace(/\\/g, '/')) ?? rawPath; - return normalizedPath; - }) - .filter((path) => isPathWithinHome(path)) - .map((path) => ({ - path, - name: path.split('/').pop() || path - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [pinnedPaths, isPathWithinHome, stripTrailingSlashes]); - - const loadDirectory = React.useCallback(async (path: string): Promise => { - const shouldInclude = (name: string) => showHidden || !name.startsWith('.'); - const normalizedHome = effectiveRoot; - - if (!rootReady || !normalizedHome) { - return []; - } - - const normalizedPathRaw = path && path.length > 0 ? path : normalizedHome; - const normalizedPath = normalizedPathRaw ? normalizedPathRaw.replace(/\\/g, '/') : null; - const normalizedTarget = normalizedPath - ? stripTrailingSlashes(normalizedPath) ?? normalizedPath - : null; - - if (normalizedTarget) { - const homePrefix = `${normalizedHome}/`; - const withinHome = normalizedTarget === normalizedHome || normalizedTarget.startsWith(homePrefix); - if (!withinHome) { - return []; - } - } - - try { - const filesystemEntries = await opencodeClient.listLocalDirectory(path); - return filesystemEntries - .filter((entry) => { - if (!entry.isDirectory) { - return false; - } - if (!shouldInclude(entry.name)) { - return false; - } - const normalizedEntryRaw = entry.path.replace(/\\/g, '/'); - const normalizedEntryPath = stripTrailingSlashes(normalizedEntryRaw) ?? normalizedEntryRaw; - const entryPrefix = normalizedEntryPath === normalizedHome ? normalizedHome : `${normalizedHome}/`; - return normalizedEntryPath === normalizedHome || normalizedEntryPath.startsWith(entryPrefix); - }) - .map((entry) => ({ - name: entry.name, - path: entry.path.replace(/\\/g, '/'), - isDirectory: true - })) - .sort((a, b) => a.name.localeCompare(b.name)); - } catch { - try { - - const tempClient = opencodeClient.getApiClient(); - const response = await tempClient.file.list({ - path: '.', - directory: path - }); - - if (!response.data) { - return []; - } - - return response.data - .filter((item: { type?: string; name?: string; absolute?: string; path?: string }) => { - if (item.type !== 'directory') { - return false; - } - if (!item.name || !shouldInclude(item.name)) { - return false; - } - const rawPath = String(item.absolute || item.path || item.name).replace(/\\/g, '/'); - const absolutePath = stripTrailingSlashes(rawPath) ?? rawPath; - const entryPrefix = absolutePath === normalizedHome ? normalizedHome : `${normalizedHome}/`; - return absolutePath === normalizedHome || absolutePath.startsWith(entryPrefix); - }) - .map((item: { name?: string; absolute?: string; path?: string }) => ({ - name: item.name || '', - path: String(item.absolute || item.path || item.name).replace(/\\/g, '/'), - isDirectory: true - })) - .filter((item): item is DirectoryItem => item.name !== '') - .sort((a: DirectoryItem, b: DirectoryItem) => a.name.localeCompare(b.name)); - } catch { - return []; - } - } - }, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]); - - const hasLoadedOnce = React.useRef(false); - - const loadInitialDirectories = React.useCallback(async () => { - if (!rootReady || !effectiveRoot) { - setIsLoading(true); - setDirectories([]); - return; - } - - // Only show loading on initial load, not on refreshes (e.g., showHidden toggle) - if (!hasLoadedOnce.current) { - setIsLoading(true); - } - try { - const homeContents = await loadDirectory(effectiveRoot); - setDirectories(homeContents); - hasLoadedOnce.current = true; - } catch { /* ignored */ } finally { - setIsLoading(false); - } - }, [rootReady, effectiveRoot, loadDirectory]); - - React.useEffect(() => { - if (!rootReady) { - return; - } - if ((variant === 'inline' || isOpen)) { - loadInitialDirectories(); - } - }, [variant, isOpen, rootReady, loadInitialDirectories]); - - const toggleExpanded = async (item: DirectoryItem) => { - if (!rootReady) { - return; - } - const isCurrentlyExpanded = expandedPaths.has(item.path); - const newExpanded = new Set(expandedPaths); - - if (isCurrentlyExpanded) { - newExpanded.delete(item.path); - setExpandedPaths(newExpanded); - return; - } - - newExpanded.add(item.path); - setExpandedPaths(newExpanded); - - const children = await loadDirectory(item.path); - const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { - return items.map((i) => { - if (i.path === item.path) { - return { ...i, children }; - } - if (i.children) { - return { ...i, children: updateItems(i.children) }; - } - return i; - }); - }; - setDirectories((prev) => updateItems(prev)); - }; - - const togglePinnedExpanded = async (path: string) => { - if (!rootReady) { - return; - } - const isCurrentlyExpanded = pinnedExpandedPaths.has(path); - const newExpanded = new Set(pinnedExpandedPaths); - - if (isCurrentlyExpanded) { - newExpanded.delete(path); - setPinnedExpandedPaths(newExpanded); - return; - } - - newExpanded.add(path); - setPinnedExpandedPaths(newExpanded); - - const children = await loadDirectory(path); - setPinnedItemChildren(prev => { - const next = new Map(prev); - next.set(path, children); - return next; - }); - }; - - React.useEffect(() => { - if (creatingInPath && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [creatingInPath]); - - const generateUniqueDirName = (parentPath: string, children: DirectoryItem[] = []): string => { - const baseName = 'new_directory'; - const existingNames = children.map(child => child.name); - - if (!existingNames.includes(baseName)) { - return baseName; - } - - let maxNumber = 1; - const numberPattern = new RegExp(`^${baseName}(\\d+)$`); - - for (const name of existingNames) { - const match = name.match(numberPattern); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNumber) { - maxNumber = num; - } - } - } - - let counter = 2; - while (existingNames.includes(`${baseName}${counter}`)) { - counter++; - } - - return `${baseName}${Math.max(counter, maxNumber + 1)}`; - }; - - const startCreatingDirectory = async (parentItem: DirectoryItem) => { - if (!rootReady) { - return; - } - - if (!expandedPaths.has(parentItem.path)) { - const newExpanded = new Set(expandedPaths); - newExpanded.add(parentItem.path); - setExpandedPaths(newExpanded); - - if (!parentItem.children) { - const children = await loadDirectory(parentItem.path); - const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { - return items.map(i => { - if (i.path === parentItem.path) { - return { ...i, children }; - } - if (i.children) { - return { ...i, children: updateItems(i.children) }; - } - return i; - }); - }; - setDirectories((prev) => updateItems(prev)); - - const uniqueName = generateUniqueDirName(parentItem.path, children); - setNewDirName(uniqueName); - } else { - const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children); - setNewDirName(uniqueName); - } - } else { - const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children); - setNewDirName(uniqueName); - } - - setCreatingInPath(parentItem.path); - }; - - const createDirectory = async () => { - if (!creatingInPath || !rootReady) return; - - const dirName = newDirName.trim() || 'new_directory'; - const fullPath = `${creatingInPath}/${dirName}`; - - try { - await opencodeClient.createDirectory(fullPath); - - const children = await loadDirectory(creatingInPath); - const updateItems = (items: DirectoryItem[]): DirectoryItem[] => { - return items.map(i => { - if (i.path === creatingInPath) { - return { ...i, children }; - } - if (i.children) { - return { ...i, children: updateItems(i.children) }; - } - return i; - }); - }; - setDirectories((prev) => updateItems(prev)); - - setCreatingInPath(null); - setNewDirName(''); - } catch (error) { - console.error('Failed to create directory:', error); - - } - }; - - const cancelCreatingDirectory = () => { - setCreatingInPath(null); - setNewDirName(''); - }; - - const renderTreeItem = (item: DirectoryItem, level: number = 0) => { - const isExpanded = expandedPaths.has(item.path); - const hasChildren = item.isDirectory; - const isPinned = pinnedPaths.has(item.path); - const isSelected = currentPath === item.path; - const isInlineVariant = variant === 'inline'; - const isDisabled = isPathDisabled(item.path); - - const rowContent = ( - <> - {hasChildren && ( - - )} - {!hasChildren &&
} - - - - - - - - ); - - if (variant === 'inline') { - return ( -
-
- {rowContent} -
- {isExpanded && ( - <> - {creatingInPath === item.path && ( -
-
- - setNewDirName(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === 'Enter') { - e.preventDefault(); - createDirectory(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelCreatingDirectory(); - } - }} - onBlur={createDirectory} - className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground" - placeholder={t('directoryTree.field.newDirectoryPlaceholder')} - /> - - -
- )} - {item.children && item.children.map((child) => renderTreeItem(child, level + 1))} - - )} -
- ); - } - - return ( -
- { - e.preventDefault(); - }} - > - {hasChildren && ( - - )} - {!hasChildren &&
} - {rowContent} - - {isExpanded && ( -
- {creatingInPath === item.path && ( -
-
- - setNewDirName(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === 'Enter') { - e.preventDefault(); - createDirectory(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelCreatingDirectory(); - } - }} - onBlur={createDirectory} - className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground" - placeholder={t('directoryTree.field.newDirectoryPlaceholder')} - /> - - -
- )} - {item.children && item.children.map((child) => renderTreeItem(child, level + 1))} -
- )} -
- ); - }; - - const renderPinnedTreeItem = (item: DirectoryItem, level: number = 0) => { - const isExpanded = pinnedExpandedPaths.has(item.path); - const children = pinnedItemChildren.get(item.path); - const hasChildren = item.isDirectory; - const isPinned = pinnedPaths.has(item.path); - const isSelected = currentPath === item.path; - const isInlineVariant = variant === 'inline'; - - const rowContent = ( - <> - {hasChildren && ( - - )} - {!hasChildren &&
} - - - - - - ); - - if (isInlineVariant) { - return ( -
-
- {rowContent} -
- {isExpanded && children && children.map((child) => renderPinnedTreeItem(child, level + 1))} -
- ); - } - - return ( -
- { - e.preventDefault(); - }} - > - {rowContent} - - {isExpanded && children && ( -
- {children.map((child) => renderPinnedTreeItem(child, level + 1))} -
- )} -
- ); - }; - - const renderPinnedRow = (name: string, path: string) => { - const isExpanded = pinnedExpandedPaths.has(path); - const children = pinnedItemChildren.get(path); - const isSelected = currentPath === path; - const isInlineVariant = variant === 'inline'; - - if (isInlineVariant) { - return ( -
-
- - - - -
- {isExpanded && children && children.map((child) => renderPinnedTreeItem(child, 1))} -
- ); - } - - return ( -
- { - e.preventDefault(); - handleDirectorySelect(path); - if (selectionBehavior === 'immediate') { - setIsOpen(false); - } - }} - className={cn( - 'flex items-start gap-2 cursor-pointer group py-2', - currentPath === path && 'bg-interactive-selection' - )} - > - - -
-
{name}
-
- {formatPathForDisplay(path, homeDirectory)} -
-
- -
- {isExpanded && children && ( -
- {children.map((child) => renderPinnedTreeItem(child, 1))} -
- )} -
- ); - }; - - const directoryContent = ( - <> - {!rootReady ? ( -
- {t('directoryTree.state.locatingHomeDirectory')} -
- ) : ( - <> - {pinnedDirectories.length > 0 && ( - <> - - {isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))} - {variant === 'dropdown' && } - {variant === 'inline' && isPinnedExpanded && ( -
- )} - - )} - -
- {t('directoryTree.section.browse')} -
- - {isLoading ? ( -
- {t('directoryTree.state.loading')} -
- ) : ( - directories.map((item) => renderTreeItem(item)) - )} - - {!isLoading && directories.length === 0 && ( -
- {t('directoryTree.state.noDirectoriesFound')} -
- )} - - )} - - ); - - if (variant === 'inline') { - return ( -
- - {directoryContent} - -
- ); - } - - return ( - - - - - - - {directoryContent} - - - - ); -}; diff --git a/packages/ui/src/components/session/sidebar/activitySections.ts b/packages/ui/src/components/session/sidebar/activitySections.ts index 68c857a5..a01daa27 100644 --- a/packages/ui/src/components/session/sidebar/activitySections.ts +++ b/packages/ui/src/components/session/sidebar/activitySections.ts @@ -1,6 +1,6 @@ import type { Session } from '@opencode-ai/sdk/v2'; -export const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000; +const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000; const isSubtaskSession = (session: Session): boolean => { return Boolean((session as Session & { parentID?: string | null }).parentID); @@ -22,7 +22,7 @@ const getSessionUpdatedAt = (session: Session): number => { return 0; }; -export const sortSessionsByUpdated = (sessions: Session[]): Session[] => { +const sortSessionsByUpdated = (sessions: Session[]): Session[] => { return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a)); }; @@ -42,5 +42,3 @@ export const deriveRecentSessions = ( }); return sortSessionsByUpdated(recent); }; - -export const getSessionUpdatedAtMs = getSessionUpdatedAt; diff --git a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts index 1722209d..54f7c3cf 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts @@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils'; -export type ProjectForArchivedFolders = { +type ProjectForArchivedFolders = { normalizedPath: string; }; diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index 47c88ce0..e908d731 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -146,20 +146,6 @@ export const compareSessionsByPinnedAndTime = ( return getSessionUpdatedAt(b) - getSessionUpdatedAt(a); }; -export const compareSessionsByPinnedAndCreated = ( - a: Session, - b: Session, - pinnedSessionIds: Set, -): number => { - const aPinned = pinnedSessionIds.has(a.id); - const bPinned = pinnedSessionIds.has(b.id); - if (aPinned !== bPinned) { - return aPinned ? -1 : 1; - } - - return getSessionCreatedAt(b) - getSessionCreatedAt(a); -}; - export const dedupeSessionsById = (sessions: Session[]): Session[] => { const byId = new Map(); sessions.forEach((session) => { diff --git a/packages/ui/src/components/ui/JsonTreeView.tsx b/packages/ui/src/components/ui/JsonTreeView.tsx index 78d33a77..a498a334 100644 --- a/packages/ui/src/components/ui/JsonTreeView.tsx +++ b/packages/ui/src/components/ui/JsonTreeView.tsx @@ -99,4 +99,3 @@ const JsonTreeView = React.memo(function JsonTreeView({ }); export { JsonTreeView }; -export type { JsonTreeViewProps }; diff --git a/packages/ui/src/components/ui/JsonTreeViewer.tsx b/packages/ui/src/components/ui/JsonTreeViewer.tsx index d5b7b0e1..9110e5af 100644 --- a/packages/ui/src/components/ui/JsonTreeViewer.tsx +++ b/packages/ui/src/components/ui/JsonTreeViewer.tsx @@ -277,4 +277,3 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () ); export { JsonTreeViewer }; -export type { JsonTreeViewerProps }; diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 901d8f80..436be6b8 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -99,7 +99,7 @@ const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; empty ); }; -export const DebugPanel: React.FC = ({ onClose }) => { +const DebugPanel: React.FC = ({ onClose }) => { const { t } = useI18n(); const [activeTab, setActiveTab] = React.useState('memory'); const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle'); diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index b43b1502..acf21d3b 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -140,5 +140,3 @@ export const MobileOverlayPanel: React.FC = ({ return createPortal(content, overlayRootRef.current); }; - -export default MobileOverlayPanel; diff --git a/packages/ui/src/components/ui/OpenCodeIcon.tsx b/packages/ui/src/components/ui/OpenCodeIcon.tsx deleted file mode 100644 index 6f300e6b..00000000 --- a/packages/ui/src/components/ui/OpenCodeIcon.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -interface OpenCodeIconProps { - className?: string; - width?: number; - height?: number; -} - -export const OpenCodeIcon: React.FC = ({ - className = '', - width = 70, - height = 70 -}) => { - return ( - - - - - ); -}; \ No newline at end of file diff --git a/packages/ui/src/components/ui/OpenCodeLogo.tsx b/packages/ui/src/components/ui/OpenCodeLogo.tsx deleted file mode 100644 index e1af3e92..00000000 --- a/packages/ui/src/components/ui/OpenCodeLogo.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -interface OpenCodeLogoProps { - className?: string; - width?: number; - height?: number; -} - -export const OpenCodeLogo: React.FC = ({ - className = '', - width = 288, - height = 50 -}) => { - return ( - - - - - - - - - - - - - - - - - - - ); -}; \ No newline at end of file diff --git a/packages/ui/src/components/ui/TextLoop.tsx b/packages/ui/src/components/ui/TextLoop.tsx deleted file mode 100644 index 42db831f..00000000 --- a/packages/ui/src/components/ui/TextLoop.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { cn } from '@/lib/utils'; -import { - motion, - AnimatePresence, -} from 'motion/react'; -import type { - Transition, - Variants, - AnimatePresenceProps, -} from 'motion/react'; -import { useState, useEffect, Children } from 'react'; - -export type TextLoopProps = { - children: React.ReactNode[]; - className?: string; - interval?: number; - transition?: Transition; - variants?: Variants; - onIndexChange?: (index: number) => void; - trigger?: boolean; - mode?: AnimatePresenceProps['mode']; -}; - -export function TextLoop({ - children, - className, - interval = 2, - transition = { duration: 0.3 }, - variants, - onIndexChange, - trigger = true, - mode = 'popLayout', -}: TextLoopProps) { - const [currentIndex, setCurrentIndex] = useState(0); - const items = Children.toArray(children); - - useEffect(() => { - let next = currentIndex; - if (items.length === 0) { - next = 0; - } else if (!Number.isInteger(currentIndex) || currentIndex < 0) { - next = 0; - } else if (currentIndex >= items.length) { - next = items.length - 1; - } - - if (next !== currentIndex) { - setCurrentIndex(next); - onIndexChange?.(next); - } - }, [currentIndex, items.length, onIndexChange]); - - useEffect(() => { - if (!trigger || items.length <= 1) return; - - const intervalMs = interval * 1000; - const timer = setInterval(() => { - setCurrentIndex((current) => { - const next = (current + 1) % items.length; - onIndexChange?.(next); - return next; - }); - }, intervalMs); - return () => clearInterval(timer); - }, [items.length, interval, onIndexChange, trigger]); - - const motionVariants: Variants = { - initial: { y: 20, opacity: 0 }, - animate: { y: 0, opacity: 1 }, - exit: { y: -20, opacity: 0 }, - }; - - return ( -
- {/* Invisible element to maintain consistent width based on longest item */} -
- {items.map((item, i) => ( -
{item}
- ))} -
- {/* Animated visible element */} -
- - - {items[currentIndex]} - - -
-
- ); -} diff --git a/packages/ui/src/components/ui/alert.tsx b/packages/ui/src/components/ui/alert.tsx deleted file mode 100644 index f3bcaad0..00000000 --- a/packages/ui/src/components/ui/alert.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-xl border px-4 py-3 typography-ui-label grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Alert, AlertTitle, AlertDescription } diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index 328b4b8a..dd79e144 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -114,5 +114,4 @@ function Button({ ) } -// eslint-disable-next-line react-refresh/only-export-components -export { Button, buttonVariants } +export { Button } diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx index 87fd1292..6cfc0a8c 100644 --- a/packages/ui/src/components/ui/card.tsx +++ b/packages/ui/src/components/ui/card.tsx @@ -38,29 +38,6 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { ) } -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { ) } -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - export { Card, CardHeader, - CardFooter, CardTitle, - CardAction, - CardDescription, CardContent, } diff --git a/packages/ui/src/components/ui/command.tsx b/packages/ui/src/components/ui/command.tsx index ba3c63ff..a6e3615d 100644 --- a/packages/ui/src/components/ui/command.tsx +++ b/packages/ui/src/components/ui/command.tsx @@ -6,13 +6,6 @@ import { Command as CommandPrimitive } from "cmdk" import { cn } from "@/lib/utils" import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { Icon } from "@/components/icon/Icon"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" function Command({ className, @@ -38,38 +31,6 @@ function Command({ ) } -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - className, - showCloseButton = true, - ...props -}: Omit, "children"> & { - title?: string - description?: string - className?: string - showCloseButton?: boolean - children?: React.ReactNode -}) { - return ( - - - {title} - {description} - - - - {children} - - - - ) -} - const CommandInput = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -232,7 +193,6 @@ function CommandShortcut({ export { Command, - CommandDialog, CommandInput, CommandList, CommandEmpty, diff --git a/packages/ui/src/components/ui/context-menu.tsx b/packages/ui/src/components/ui/context-menu.tsx index 15c38a4e..9812f3fc 100644 --- a/packages/ui/src/components/ui/context-menu.tsx +++ b/packages/ui/src/components/ui/context-menu.tsx @@ -1,13 +1,11 @@ import * as React from "react"; import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu"; -import { Icon } from "@/components/icon/Icon"; import { cn } from "@/lib/utils"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, - dropdownMenuSubTriggerClass, } from "./dropdown-menu.styles"; function ContextMenu({ ...props }: React.ComponentProps) { @@ -53,47 +51,10 @@ function ContextMenuSeparator({ className, ...props }: React.ComponentProps; } -function ContextMenuSub({ ...props }: React.ComponentProps) { - return ; -} - -function ContextMenuSubTrigger({ className, children, ...props }: React.ComponentProps) { - return ( - - {children} - - - ); -} - -function ContextMenuSubContent({ className, positionerClassName, children, style, ...props }: ContentProps) { - return ( - - - - {children} - - - - ); -} - export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubTrigger, - ContextMenuSubContent, }; diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index bdccc80e..b01f2db2 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -41,15 +41,6 @@ function DialogPortal({ return } -function DialogClose({ - asChild, - children, - ...props -}: React.ComponentProps & AsChildProps) { - const r = renderFromAsChild(asChild, children); - return -} - const DialogOverlay = React.forwardRef< HTMLDivElement, React.ComponentPropsWithoutRef @@ -177,13 +168,10 @@ function DialogDescription({ export { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, - DialogOverlay, - DialogPortal, DialogTitle, DialogTrigger, } diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 12d49e42..16215d81 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -48,13 +48,6 @@ function DropdownMenu({ ) } -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - const portalContext = React.useContext(DropdownPortalContext); - return -} - function DropdownMenuTrigger({ asChild, children, @@ -147,12 +140,6 @@ function DropdownMenuContent({ ) } -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return -} - function DropdownMenuItem({ className, inset, @@ -188,32 +175,6 @@ function DropdownMenuItem({ ) } -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { @@ -277,22 +238,6 @@ function DropdownMenuSeparator({ ) } -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - function DropdownMenuSub({ ...props }: React.ComponentProps) { @@ -353,17 +298,13 @@ function DropdownMenuSubContent({ export { DropdownMenu, - DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, - DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, diff --git a/packages/ui/src/components/ui/fancy-button.tsx b/packages/ui/src/components/ui/fancy-button.tsx deleted file mode 100644 index 809c7d45..00000000 --- a/packages/ui/src/components/ui/fancy-button.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; -import { Slot } from "@/components/ui/slot"; - -// Flat tinted fancy-button: pale tinted fill + saturated tinted border + -// saturated tinted text. Matches the Button primitive design language. -const TINT_PRIMARY = [ - "bg-[color-mix(in_srgb,var(--primary-base)_10%,var(--background))]", - "text-[var(--primary-base)]", - "border border-[color-mix(in_srgb,var(--primary-base)_12%,transparent)]", - "hover:bg-[color-mix(in_srgb,var(--primary-base)_16%,var(--background))]", - "active:bg-[color-mix(in_srgb,var(--primary-base)_22%,var(--background))]", - "dark:bg-[color-mix(in_srgb,var(--primary-base)_16%,transparent)]", - "dark:border-[color-mix(in_srgb,var(--primary-base)_20%,transparent)]", - "dark:hover:bg-[color-mix(in_srgb,var(--primary-base)_22%,transparent)]", - "dark:active:bg-[color-mix(in_srgb,var(--primary-base)_30%,transparent)]", -].join(" "); - -const TINT_DESTRUCTIVE = [ - "bg-[color-mix(in_srgb,var(--status-error)_7%,var(--background))]", - "text-[var(--status-error)]", - "border border-[color-mix(in_srgb,var(--status-error)_9%,transparent)]", - "hover:bg-[color-mix(in_srgb,var(--status-error)_11%,var(--background))]", - "active:bg-[color-mix(in_srgb,var(--status-error)_16%,var(--background))]", - "dark:bg-[color-mix(in_srgb,var(--status-error)_9%,transparent)]", - "dark:border-[color-mix(in_srgb,var(--status-error)_14%,transparent)]", - "dark:hover:bg-[color-mix(in_srgb,var(--status-error)_14%,transparent)]", - "dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]", -].join(" "); - -const fancyButtonRoot = cva( - [ - "group relative inline-flex items-center justify-center whitespace-nowrap [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] typography-ui-label outline-none", - "transition-[background-color,border-color,color,opacity] duration-150 ease-out", - "focus:outline-none", - "disabled:pointer-events-none disabled:text-muted-foreground/60", - "disabled:bg-interactive-hover", - ], - { - variants: { - variant: { - neutral: - "bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active", - primary: TINT_PRIMARY, - destructive: TINT_DESTRUCTIVE, - basic: - "bg-background text-foreground border border-border/60 hover:bg-interactive-hover hover:text-foreground", - }, - size: { - medium: "h-10 gap-3 rounded-[var(--radius-xl)] px-3.5", - small: "h-9 gap-3 rounded-[var(--radius-lg)] px-3", - xsmall: "h-8 gap-2 rounded-[var(--radius-lg)] px-2.5", - }, - }, - defaultVariants: { - variant: "neutral", - size: "medium", - }, - }, -); - -const fancyButtonIcon = cva("relative z-10 size-5 shrink-0", { - variants: { - size: { - medium: "-mx-1", - small: "-mx-1", - xsmall: "-mx-1", - }, - }, - defaultVariants: { - size: "medium", - }, -}); - -type FancyButtonVariants = VariantProps; - -type FancyButtonContextValue = Pick; - -const FancyButtonContext = React.createContext({}); - -type RootProps = FancyButtonVariants & - React.ButtonHTMLAttributes & { - asChild?: boolean; - }; - -const FancyButtonRoot = React.forwardRef( - ({ asChild, children, variant, size, className, ...rest }, ref) => { - const Component = (asChild ? Slot : "button") as React.ElementType; - const ctx = React.useMemo( - () => ({ variant, size }), - [variant, size], - ); - - return ( - - - {children} - - - ); - }, -); -FancyButtonRoot.displayName = "FancyButton.Root"; - -type IconProps = { - as?: T; - className?: string; -} & Omit, "as" | "className">; - -function FancyButtonIcon({ - as, - className, - ...rest -}: IconProps) { - const { size } = React.useContext(FancyButtonContext); - const Component = (as ?? "div") as React.ElementType; - return ( - - ); -} -FancyButtonIcon.displayName = "FancyButton.Icon"; - -export { FancyButtonRoot as Root, FancyButtonIcon as Icon }; -// eslint-disable-next-line react-refresh/only-export-components -export { fancyButtonRoot as fancyButtonVariants }; diff --git a/packages/ui/src/components/ui/index.ts b/packages/ui/src/components/ui/index.ts index 3cba6289..c33bec24 100644 --- a/packages/ui/src/components/ui/index.ts +++ b/packages/ui/src/components/ui/index.ts @@ -1,2 +1 @@ -export { Toaster } from './sonner' -export { toast } from './toast' \ No newline at end of file +export { toast } from './toast' diff --git a/packages/ui/src/components/ui/number-input.tsx b/packages/ui/src/components/ui/number-input.tsx index 07b2a8d3..9c83c4b3 100644 --- a/packages/ui/src/components/ui/number-input.tsx +++ b/packages/ui/src/components/ui/number-input.tsx @@ -5,7 +5,7 @@ import { useI18n } from "@/lib/i18n" import { cn } from "@/lib/utils" import { Icon } from "@/components/icon/Icon"; -export interface NumberInputProps +interface NumberInputProps extends Omit, "value" | "onChange" | "type"> { value?: number onValueChange: (value: number) => void diff --git a/packages/ui/src/components/ui/scroll-area.tsx b/packages/ui/src/components/ui/scroll-area.tsx deleted file mode 100644 index daa2600a..00000000 --- a/packages/ui/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react" -import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area" - -import { cn } from "@/lib/utils" - -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ) -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { ScrollArea, ScrollBar } diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index a6b7e92b..4d9e1f6b 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -272,50 +272,12 @@ function SelectSeparator({ ) } -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, diff --git a/packages/ui/src/components/ui/separator.tsx b/packages/ui/src/components/ui/separator.tsx deleted file mode 100644 index 56fb040e..00000000 --- a/packages/ui/src/components/ui/separator.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client" - -import * as React from "react" -import { Separator as BaseSeparator } from "@base-ui/react/separator" - -import { cn } from "@/lib/utils" - -function Separator({ - className, - orientation = "horizontal", - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Separator } diff --git a/packages/ui/src/components/ui/slider.tsx b/packages/ui/src/components/ui/slider.tsx deleted file mode 100644 index 3c79b842..00000000 --- a/packages/ui/src/components/ui/slider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from 'react'; -import { cn } from '@/lib/utils'; - -interface SliderProps { - value: number; - onChange: (value: number) => void; - min?: number; - max?: number; - step?: number; - disabled?: boolean; - className?: string; - label?: string; - valueFormatter?: (value: number) => string; -} - -/** - * Range slider component for numeric input - * Uses native range input styled with Tailwind CSS - */ -export const Slider: React.FC = ({ - value, - onChange, - min = 0, - max = 1, - step = 0.1, - disabled = false, - className, - label, - valueFormatter = (v) => v.toFixed(1), -}) => { - return ( -
-
- onChange(parseFloat(e.target.value))} - disabled={disabled} - className={cn( - 'w-full h-2 rounded-lg appearance-none cursor-pointer bg-muted', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - 'disabled:cursor-not-allowed disabled:opacity-50', - '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4', - '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary', - '[&::-webkit-slider-thumb]:shadow-none [&::-webkit-slider-thumb]:transition-transform', - '[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:active:scale-95', - '[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full', - '[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-none' - )} - aria-label={label} - aria-valuemin={min} - aria-valuemax={max} - aria-valuenow={value} - /> -
- - {valueFormatter(value)} - -
- ); -}; diff --git a/packages/ui/src/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx index 74c85d71..f57b3c0e 100644 --- a/packages/ui/src/components/ui/textarea.tsx +++ b/packages/ui/src/components/ui/textarea.tsx @@ -213,29 +213,4 @@ const Textarea = React.forwardRef( Textarea.displayName = "Textarea" -function TextareaCharCounter({ - current, - max, - className, -}: { - current?: number; - max?: number; - className?: string; -}) { - if (current === undefined || max === undefined) return null; - const isError = current > max; - return ( - - {current}/{max} - - ); -} - -export { Textarea, TextareaCharCounter } +export { Textarea } diff --git a/packages/ui/src/components/ui/toggle.tsx b/packages/ui/src/components/ui/toggle.tsx deleted file mode 100644 index e0550b6b..00000000 --- a/packages/ui/src/components/ui/toggle.tsx +++ /dev/null @@ -1,47 +0,0 @@ - -import * as React from "react" -import { Toggle as BaseToggle } from "@base-ui/react/toggle" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-xl typography-ui-label font-medium hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[pressed]:bg-interactive-selection data-[pressed]:text-interactive-selection-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-none hover:bg-interactive-hover hover:text-foreground", - }, - size: { - default: "h-9 px-2 min-w-9", - sm: "h-8 px-1.5 min-w-8", - lg: "h-10 px-2.5 min-w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Toggle({ - className, - variant, - size, - ...props -}: React.ComponentProps & - VariantProps) { - return ( - - ) -} - -// eslint-disable-next-line react-refresh/only-export-components -export { Toggle, toggleVariants } diff --git a/packages/ui/src/components/ui/typewriter-text.tsx b/packages/ui/src/components/ui/typewriter-text.tsx deleted file mode 100644 index 92928ead..00000000 --- a/packages/ui/src/components/ui/typewriter-text.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; - -export type TypewriterTextProps = { - children: string; - speed?: number; - loop?: boolean; - className?: string; -}; - -const LOOP_RESTART_DELAY_MS = 1000; - -export const TypewriterText: React.FC = ({ - children, - speed = 50, - loop = false, - className = '', -}) => { - const [displayed, setDisplayed] = useState(''); - const index = useRef(0); - const timeout = useRef(null); - - useEffect(() => { - setDisplayed(''); - index.current = 0; - - function type() { - setDisplayed(children.slice(0, index.current + 1)); - if (index.current < children.length - 1) { - index.current += 1; - timeout.current = setTimeout(type, speed); - } else if (loop) { - timeout.current = setTimeout(() => { - setDisplayed(''); - index.current = 0; - type(); - }, LOOP_RESTART_DELAY_MS); - } - } - - if (children && children.length > 0) { - type(); - } else { - setDisplayed(''); - } - - return () => { - if (timeout.current) { - clearTimeout(timeout.current); - } - }; - }, [children, speed, loop]); - - if (!children) { - return null; - } - - return {displayed}; -}; - -export default TypewriterText; - diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index fa1d21ac..41b55e1a 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount, useGitLoadingStatus } from '@/stores/useGitStore'; +import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; import { cn } from '@/lib/utils'; import type { GitStatus } from '@/lib/api/types'; import { @@ -1694,22 +1694,3 @@ export const DiffView: React.FC = ({
); }; - -// eslint-disable-next-line react-refresh/only-export-components -export const useDiffFileCount = (): number => { - const { git } = useRuntimeAPIs(); - const effectiveDirectory = useEffectiveDirectory(); - - const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); - const ensureStatus = useGitStore((state) => state.ensureStatus); - const fileCount = useGitFileCount(effectiveDirectory ?? null); - - React.useEffect(() => { - if (effectiveDirectory) { - setActiveDirectory(effectiveDirectory); - void ensureStatus(effectiveDirectory, git); - } - }, [effectiveDirectory, setActiveDirectory, ensureStatus, git]); - - return fileCount; -}; diff --git a/packages/ui/src/components/views/agent-manager/index.ts b/packages/ui/src/components/views/agent-manager/index.ts index 96cef30f..6c76255f 100644 --- a/packages/ui/src/components/views/agent-manager/index.ts +++ b/packages/ui/src/components/views/agent-manager/index.ts @@ -1,3 +1 @@ export { AgentManagerView } from './AgentManagerView'; -export { AgentManagerSidebar } from './AgentManagerSidebar'; -export { AgentManagerEmptyState } from './AgentManagerEmptyState'; diff --git a/packages/ui/src/components/views/git/GitGraphSegment.tsx b/packages/ui/src/components/views/git/GitGraphSegment.tsx index d5035d4f..1a6ba28f 100644 --- a/packages/ui/src/components/views/git/GitGraphSegment.tsx +++ b/packages/ui/src/components/views/git/GitGraphSegment.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { LanedCommit } from './gitGraph'; -export const LANE_WIDTH = 8; +const LANE_WIDTH = 8; interface GitGraphSegmentProps { laned: LanedCommit; diff --git a/packages/ui/src/components/views/git/changesTree.ts b/packages/ui/src/components/views/git/changesTree.ts index bf3d6fd0..4e5d9c58 100644 --- a/packages/ui/src/components/views/git/changesTree.ts +++ b/packages/ui/src/components/views/git/changesTree.ts @@ -25,7 +25,7 @@ export type FlattenedTreeRow = file: GitStatus['files'][number]; }; -export const normalizePathForTree = (value: string): string => +const normalizePathForTree = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '').trim(); const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({ diff --git a/packages/ui/src/components/views/git/gitGraph.ts b/packages/ui/src/components/views/git/gitGraph.ts index 36d77765..066ba868 100644 --- a/packages/ui/src/components/views/git/gitGraph.ts +++ b/packages/ui/src/components/views/git/gitGraph.ts @@ -1,6 +1,6 @@ import type { GitLogEntry } from '@/lib/api/types'; -export type LaneColor = string; +type LaneColor = string; /** * Describes one visible line/curve in a commit row's SVG. @@ -14,7 +14,7 @@ export type LaneColor = string; * - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens * - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here */ -export interface ConnectorSegment { +interface ConnectorSegment { fromLane: number; toLane: number; color: LaneColor; @@ -40,7 +40,7 @@ const LANE_COLORS: LaneColor[] = [ 'var(--status-info)', ]; -export function laneColor(lane: number): LaneColor { +function laneColor(lane: number): LaneColor { return LANE_COLORS[lane % LANE_COLORS.length]; } diff --git a/packages/ui/src/components/views/git/gitIndexMutationQueue.ts b/packages/ui/src/components/views/git/gitIndexMutationQueue.ts index 737130ca..f494d3f5 100644 --- a/packages/ui/src/components/views/git/gitIndexMutationQueue.ts +++ b/packages/ui/src/components/views/git/gitIndexMutationQueue.ts @@ -1,6 +1,6 @@ export type GitIndexMutationDirection = 'stage' | 'unstage'; -export type QueuedGitIndexMutation = { +type QueuedGitIndexMutation = { directory: string; direction: GitIndexMutationDirection; paths: Set; diff --git a/packages/ui/src/components/views/git/index.ts b/packages/ui/src/components/views/git/index.ts deleted file mode 100644 index 3709d3c6..00000000 --- a/packages/ui/src/components/views/git/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { GitHeader } from './GitHeader'; -export { GitEmptyState } from './GitEmptyState'; -export { ChangesPanel } from './ChangesPanel'; -export type { ChangesGroupConfig } from './ChangesPanel'; -export { ChangeRow } from './ChangeRow'; -export { CommitSection } from './CommitSection'; -export { CommitInput } from './CommitInput'; -export { AIHighlightsBox } from './AIHighlightsBox'; -export { HistorySection } from './HistorySection'; -export { HistoryCommitRow } from './HistoryCommitRow'; -export { SyncActions } from './SyncActions'; -export { BranchSelector } from './BranchSelector'; -export { BranchIntegrationSection } from './BranchIntegrationSection'; -export { ConflictDialog } from './ConflictDialog'; -export { StashDialog } from './StashDialog'; diff --git a/packages/ui/src/components/views/index.ts b/packages/ui/src/components/views/index.ts deleted file mode 100644 index 7cf76564..00000000 --- a/packages/ui/src/components/views/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Entry-level views only. Avoid barrel re-exports that pull heavy modules into the main chunk. */ -export { ChatView } from './ChatView'; diff --git a/packages/ui/src/components/voice/VoiceStatusIndicator.tsx b/packages/ui/src/components/voice/VoiceStatusIndicator.tsx index e1c7b49b..ab253105 100644 --- a/packages/ui/src/components/voice/VoiceStatusIndicator.tsx +++ b/packages/ui/src/components/voice/VoiceStatusIndicator.tsx @@ -137,5 +137,3 @@ export function VoiceStatusIndicator({
); } - -export default VoiceStatusIndicator; diff --git a/packages/ui/src/components/voice/index.ts b/packages/ui/src/components/voice/index.ts index 7ad8685c..5d388170 100644 --- a/packages/ui/src/components/voice/index.ts +++ b/packages/ui/src/components/voice/index.ts @@ -1,3 +1,2 @@ export { VoiceProvider } from './VoiceProvider'; export { BrowserVoiceButton } from './BrowserVoiceButton'; -export { VoiceStatusIndicator } from './VoiceStatusIndicator'; diff --git a/packages/ui/src/constants/sidebar.ts b/packages/ui/src/constants/sidebar.ts index 53ad2331..6654bf7e 100644 --- a/packages/ui/src/constants/sidebar.ts +++ b/packages/ui/src/constants/sidebar.ts @@ -1,83 +1 @@ -import type { IconName } from "@/components/icon/icons"; - export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'mcp' | 'providers' | 'usage' | 'git-identities' | 'settings'; - -export type IconComponent = IconName | 'mcp-custom'; - -export interface SidebarSectionConfig { - id: SidebarSection; - label: string; - description: string; - icon: IconComponent; -} - -export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [ - { - id: 'sessions', - label: 'Sessions', - description: 'Browse and manage chat sessions scoped to the current directory.', - icon: "chat-ai-3", - }, - { - id: 'agents', - label: 'Agents', - description: 'Configure OpenCode agents, prompts, and permissions.', - icon: "brain-ai-3", - }, - { - id: 'commands', - label: 'Commands', - description: 'Create and maintain custom slash commands for OpenCode.', - icon: "command", - }, - { - id: 'skills', - label: 'Skills', - description: 'Create reusable instruction files for agents to load on-demand.', - icon: "book", - }, - { - id: 'mcp', - label: 'MCP', - description: 'Manage Model Context Protocol servers and their configurations.', - icon: "mcp-custom", - }, - { - id: 'providers', - label: 'Providers', - description: 'Configure AI model providers and API credentials.', - icon: "stack", - }, - { - id: 'usage', - label: 'Usage', - description: 'Monitor API quota and usage across providers.', - icon: "bar-chart-2", - }, - { - id: 'git-identities', - label: 'Git Identities', - description: 'Manage Git profiles with different credentials and SSH keys.', - icon: "git-branch", - }, - { - id: 'settings', - label: 'OpenChamber', - description: 'OpenChamber app settings: themes, fonts, and preferences.', - icon: "settings-3", - }, -]; - -const sidebarSectionLabels = {} as Record; -const sidebarSectionDescriptions = {} as Record; -const sidebarSectionConfigMap = {} as Record; - -SIDEBAR_SECTIONS.forEach((section) => { - sidebarSectionLabels[section.id] = section.label; - sidebarSectionDescriptions[section.id] = section.description; - sidebarSectionConfigMap[section.id] = section; -}); - -export const SIDEBAR_SECTION_LABELS = sidebarSectionLabels; -export const SIDEBAR_SECTION_DESCRIPTIONS = sidebarSectionDescriptions; -export const SIDEBAR_SECTION_CONFIG_MAP = sidebarSectionConfigMap; diff --git a/packages/ui/src/contexts/DrawerContext.tsx b/packages/ui/src/contexts/DrawerContext.tsx index 8ad3c78d..3eafe4f3 100644 --- a/packages/ui/src/contexts/DrawerContext.tsx +++ b/packages/ui/src/contexts/DrawerContext.tsx @@ -27,15 +27,3 @@ export const DrawerProvider: React.FC<{ ); }; - -// eslint-disable-next-line react-refresh/only-export-components -export const useDrawer = (): DrawerContextValue => { - const context = React.useContext(DrawerContext); - if (!context) { - throw new Error('useDrawer must be used within a DrawerProvider'); - } - return context; -}; - -// eslint-disable-next-line react-refresh/only-export-components -export const useOptionalDrawer = (): DrawerContextValue | null => React.useContext(DrawerContext); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 0367a5b3..f88f1bc3 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -7,7 +7,7 @@ import { useDirectorySync, useSessionPermissions, useSessionQuestions, useSessio import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { useCurrentSessionActivity } from './useSessionActivity'; -export type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission'; +type AssistantActivity = 'idle' | 'streaming' | 'tooling' | 'cooldown' | 'permission'; interface WorkingSummary { activity: AssistantActivity; diff --git a/packages/ui/src/hooks/useAssistantTyping.ts b/packages/ui/src/hooks/useAssistantTyping.ts deleted file mode 100644 index 568f10bc..00000000 --- a/packages/ui/src/hooks/useAssistantTyping.ts +++ /dev/null @@ -1,229 +0,0 @@ -import React from 'react'; - -const DEFAULT_TIMEOUT_MS = 20000; -const LIFECYCLE_GRACE_MS = 8000; - -type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed'; - -interface MessageStreamLifecycle { - phase: MessageStreamPhase; - startedAt: number; - lastUpdateAt: number; - completedAt?: number; -} - -interface MessagePart { - type?: string; - time?: { end?: number }; - state?: { status?: string }; - text?: string; - content?: string; -} - -interface ChatMessageInfo { - id: string; - role: string; - time: { created: number; completed?: number; updated?: number }; - animationSettled?: boolean; -} - -interface ChatMessageRecord { - info: ChatMessageInfo; - parts: MessagePart[]; -} - -const hasFinalizedTextPart = (parts: MessagePart[]): boolean => { - return parts.some((part) => { - if (part?.type !== 'text') { - return false; - } - if (!part?.time || typeof part.time.end === 'undefined') { - return false; - } - const content = typeof part.text === 'string' ? part.text : part.content; - return Boolean(content && content.trim().length > 0); - }); -}; - -const getAssistantMessagesAfterLastUser = (messages: ChatMessageRecord[]): ChatMessageRecord[] => { - let lastUserIndex = -1; - - for (let i = messages.length - 1; i >= 0; i -= 1) { - if (messages[i]?.info?.role === 'user') { - lastUserIndex = i; - break; - } - } - - return messages.filter((message, index) => index > lastUserIndex && message?.info?.role === 'assistant'); -}; - -const buildAssistantActivitySignature = (messages: ChatMessageRecord[]): string => { - return messages - .map((message) => { - const partSignature = (message.parts || []) - .map((part) => { - const type = part?.type || 'unknown'; - const finalized = part?.time && typeof part.time.end !== 'undefined' ? '1' : '0'; - const status = part?.state?.status || ''; - const textLength = typeof part?.text === 'string' ? part.text.length : 0; - const contentLength = typeof part?.content === 'string' ? part.content.length : 0; - return `${type}:${finalized}:${status}:${textLength}:${contentLength}`; - }) - .join('|'); - - const completed = message.info?.time?.completed || ''; - const updated = message.info?.time?.updated || ''; - - return `${message.info?.id || 'unknown'}:${message.parts?.length || 0}:${completed}:${updated}:${partSignature}`; - }) - .join('||'); -}; - -interface UseAssistantTypingOptions { - messages: ChatMessageRecord[]; - timeoutMs?: number; - messageStreamStates?: Map; -} - -interface UseAssistantTypingResult { - isTyping: boolean; -} - -export const useAssistantTyping = ({ - messages, - timeoutMs = DEFAULT_TIMEOUT_MS, - messageStreamStates, -}: UseAssistantTypingOptions): UseAssistantTypingResult => { - const assistantMessages = React.useMemo(() => getAssistantMessagesAfterLastUser(messages), [messages]); - - const hasAssistantActivity = assistantMessages.length > 0; - const hasFinalAssistantText = assistantMessages.some((message) => hasFinalizedTextPart(message.parts)); - const assistantHasUnsettledAnimation = assistantMessages.some((message) => { - return message.info.animationSettled !== true; - }); - const hasActiveLifecycle = React.useMemo(() => { - if (!messageStreamStates || messageStreamStates.size === 0) { - return false; - } - - return assistantMessages.some((message) => { - const lifecycle = messageStreamStates.get(message.info.id); - if (!lifecycle) { - return false; - } - return lifecycle.phase === 'streaming' || lifecycle.phase === 'cooldown'; - }); - }, [assistantMessages, messageStreamStates]); - - const hasRunningTool = React.useMemo(() => { - return assistantMessages.some((message) => - (message.parts || []).some( - (part) => part?.type === 'tool' && part?.state?.status === 'running' - ) - ); - }, [assistantMessages]); - - const shouldShowBecauseOfLifecycle = hasAssistantActivity && (hasActiveLifecycle || hasRunningTool); - const shouldShowBasedOnContent = assistantHasUnsettledAnimation && hasAssistantActivity && !hasFinalAssistantText; - const [graceUntil, setGraceUntil] = React.useState(null); - const previousLifecycleRef = React.useRef(false); - - React.useEffect(() => { - if (shouldShowBecauseOfLifecycle) { - setGraceUntil(null); - } else if (previousLifecycleRef.current) { - setGraceUntil(Date.now() + LIFECYCLE_GRACE_MS); - } - - previousLifecycleRef.current = shouldShowBecauseOfLifecycle; - }, [shouldShowBecauseOfLifecycle]); - - React.useEffect(() => { - if (graceUntil === null) { - return undefined; - } - - const remaining = graceUntil - Date.now(); - if (remaining <= 0) { - setGraceUntil(null); - return undefined; - } - - const timer = window.setTimeout(() => { - setGraceUntil(null); - }, remaining); - - return () => window.clearTimeout(timer); - }, [graceUntil]); - - const withinLifecycleGrace = graceUntil !== null; - - const shouldShowIndicator = shouldShowBasedOnContent || shouldShowBecauseOfLifecycle || withinLifecycleGrace; - - const signatureRef = React.useRef(null); - const [lastActivityAt, setLastActivityAt] = React.useState(null); - const [hasTimedOut, setHasTimedOut] = React.useState(false); - - React.useEffect(() => { - if (!shouldShowIndicator) { - signatureRef.current = null; - setLastActivityAt(null); - setHasTimedOut(false); - return; - } - - const contentSignature = buildAssistantActivitySignature(assistantMessages); - const lifecycleSignature = messageStreamStates - ? assistantMessages - .map((message) => { - const lifecycle = messageStreamStates.get(message.info.id); - if (!lifecycle) { - return `${message.info.id}:none`; - } - return `${message.info.id}:${lifecycle.phase}:${lifecycle.lastUpdateAt}:${ - lifecycle.completedAt || '' - }`; - }) - .join('||') - : ''; - const signature = `${contentSignature}::${lifecycleSignature}::${hasRunningTool ? 'tool-running' : ''}::${ - graceUntil ?? 'no-grace' - }`; - - if (signatureRef.current !== signature) { - signatureRef.current = signature; - setLastActivityAt(Date.now()); - setHasTimedOut(false); - } - }, [assistantMessages, shouldShowIndicator, messageStreamStates, hasRunningTool, graceUntil]); - - React.useEffect(() => { - if (!shouldShowIndicator) { - return undefined; - } - - if (lastActivityAt === null) { - return undefined; - } - - const now = Date.now(); - const elapsed = now - lastActivityAt; - - if (elapsed >= timeoutMs) { - setHasTimedOut(true); - return undefined; - } - - const remaining = timeoutMs - elapsed; - const timer = window.setTimeout(() => { - setHasTimedOut(true); - }, remaining); - - return () => window.clearTimeout(timer); - }, [shouldShowIndicator, lastActivityAt, timeoutMs]); - - const isTyping = shouldShowIndicator && !hasTimedOut; - - return React.useMemo(() => ({ isTyping }), [isTyping]); -}; diff --git a/packages/ui/src/hooks/useAvailableTools.ts b/packages/ui/src/hooks/useAvailableTools.ts deleted file mode 100644 index f3b52442..00000000 --- a/packages/ui/src/hooks/useAvailableTools.ts +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; - -export const useAvailableTools = () => { - const { tools: toolsAPI } = useRuntimeAPIs(); - const [tools, setTools] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - let cancelled = false; - - const fetchTools = async () => { - setIsLoading(true); - setError(null); - - try { - const availableTools = await toolsAPI.getAvailableTools(); - - if (cancelled) { - return; - } - - setTools(availableTools); - } catch (err) { - if (cancelled) { - return; - } - - const message = err instanceof Error ? err.message : 'Failed to fetch tools'; - console.error('Failed to fetch available tools:', message); - setError(message); - setTools([]); - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - }; - - fetchTools(); - - return () => { - cancelled = true; - }; - }, [toolsAPI]); - - return { tools, isLoading, error }; -}; diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index ec1c7aa0..7ead5106 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -4,7 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { useViewportStore } from '@/sync/viewport-store'; -export type AutoFollowState = 'following' | 'released'; +type AutoFollowState = 'following' | 'released'; export type ContentChangeReason = 'text' | 'structural' | 'permission'; diff --git a/packages/ui/src/hooks/useDrawerSwipe.ts b/packages/ui/src/hooks/useDrawerSwipe.ts deleted file mode 100644 index 42320bcb..00000000 --- a/packages/ui/src/hooks/useDrawerSwipe.ts +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react'; -import { animate } from 'motion/react'; -import { useOptionalDrawer } from '@/contexts/DrawerContext'; - -type DrawerSwipeOptions = { - edgeSide?: 'left' | 'right'; - strictHorizontalIntent?: boolean; - horizontalIntentRatio?: number; - activationDistance?: number; - onlyWhenClosed?: boolean; -}; - -export function useDrawerSwipe(options: DrawerSwipeOptions = {}) { - const drawer = useOptionalDrawer(); - const { - edgeSide, - strictHorizontalIntent = false, - horizontalIntentRatio = 1.35, - activationDistance = 30, - onlyWhenClosed = false, - } = options; - const touchStartXRef = React.useRef(0); - const touchStartYRef = React.useRef(0); - const isHorizontalSwipeRef = React.useRef(null); - const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null); - - const handleTouchStart = React.useCallback((e: React.TouchEvent) => { - if (!drawer) return; - touchStartXRef.current = e.touches[0].clientX; - touchStartYRef.current = e.touches[0].clientY; - isHorizontalSwipeRef.current = null; - isDraggingDrawerRef.current = null; - }, [drawer]); - - const handleTouchMove = React.useCallback((e: React.TouchEvent) => { - if (!drawer) return; - const currentX = e.touches[0].clientX; - const currentY = e.touches[0].clientY; - const deltaX = currentX - touchStartXRef.current; - const deltaY = currentY - touchStartYRef.current; - - if (isHorizontalSwipeRef.current === null) { - if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) { - if (strictHorizontalIntent) { - isHorizontalSwipeRef.current = Math.abs(deltaX) > Math.abs(deltaY) * horizontalIntentRatio; - } else { - isHorizontalSwipeRef.current = Math.abs(deltaX) > Math.abs(deltaY); - } - } - } - - if (isHorizontalSwipeRef.current === true) { - if (onlyWhenClosed && (drawer.leftDrawerOpen || drawer.rightDrawerOpen)) { - return; - } - - const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85; - const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85; - - if (isDraggingDrawerRef.current === null) { - if (!edgeSide && drawer.leftDrawerOpen && deltaX > 10) { - isDraggingDrawerRef.current = 'left'; - } else if (!edgeSide && drawer.rightDrawerOpen && deltaX < -10) { - isDraggingDrawerRef.current = 'right'; - } else if (!drawer.leftDrawerOpen && !drawer.rightDrawerOpen) { - if (edgeSide === 'left') { - if (deltaX > activationDistance) { - isDraggingDrawerRef.current = 'left'; - } - } else if (edgeSide === 'right') { - if (deltaX < -activationDistance) { - isDraggingDrawerRef.current = 'right'; - } - } else if (deltaX > activationDistance) { - isDraggingDrawerRef.current = 'left'; - } else if (deltaX < -activationDistance) { - isDraggingDrawerRef.current = 'right'; - } - } - } - - if (!isDraggingDrawerRef.current) { - return; - } - - e.preventDefault(); - - if (isDraggingDrawerRef.current === 'left') { - if (drawer.leftDrawerOpen) { - const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx)); - drawer.leftDrawerX.set(-leftDrawerWidthPx * (1 - progress)); - } else { - const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx)); - drawer.leftDrawerX.set(-leftDrawerWidthPx + (leftDrawerWidthPx * progress)); - } - } - - if (isDraggingDrawerRef.current === 'right') { - if (drawer.rightDrawerOpen) { - const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx)); - drawer.rightDrawerX.set(rightDrawerWidthPx * (1 - progress)); - } else { - const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx)); - drawer.rightDrawerX.set(rightDrawerWidthPx - (rightDrawerWidthPx * progress)); - } - } - } - }, [activationDistance, drawer, edgeSide, horizontalIntentRatio, onlyWhenClosed, strictHorizontalIntent]); - - const handleTouchEnd = React.useCallback((e: React.TouchEvent) => { - if (!drawer) return; - if (isHorizontalSwipeRef.current !== true) return; - - const endX = e.changedTouches[0].clientX; - const deltaX = endX - touchStartXRef.current; - const velocityThreshold = 500; - const progressThreshold = 0.3; - - const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85; - const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85; - - if (isDraggingDrawerRef.current === 'left') { - const isOpen = drawer.leftDrawerOpen; - const currentX = drawer.leftDrawerX.get(); - const progress = isOpen - ? 1 - Math.abs(currentX) / leftDrawerWidthPx - : 1 + currentX / leftDrawerWidthPx; - - const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold; - - if (shouldComplete) { - const targetX = isOpen ? -leftDrawerWidthPx : 0; - animate(drawer.leftDrawerX, targetX, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - drawer.setMobileLeftDrawerOpen(!isOpen); - } else { - const targetX = isOpen ? 0 : -leftDrawerWidthPx; - animate(drawer.leftDrawerX, targetX, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - } - - isDraggingDrawerRef.current = null; - return; - } - - if (isDraggingDrawerRef.current === 'right') { - const isOpen = drawer.rightDrawerOpen; - const currentX = drawer.rightDrawerX.get(); - const progress = isOpen - ? 1 - Math.abs(currentX) / rightDrawerWidthPx - : 1 - currentX / rightDrawerWidthPx; - - const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold; - - if (shouldComplete) { - const targetX = isOpen ? rightDrawerWidthPx : 0; - animate(drawer.rightDrawerX, targetX, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - drawer.setRightSidebarOpen(!isOpen); - } else { - const targetX = isOpen ? 0 : rightDrawerWidthPx; - animate(drawer.rightDrawerX, targetX, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - } - - isDraggingDrawerRef.current = null; - return; - } - - isHorizontalSwipeRef.current = null; - }, [drawer]); - - return { - handleTouchStart, - handleTouchMove, - handleTouchEnd, - }; -} diff --git a/packages/ui/src/hooks/useEdgeSwipe.ts b/packages/ui/src/hooks/useEdgeSwipe.ts deleted file mode 100644 index baa69253..00000000 --- a/packages/ui/src/hooks/useEdgeSwipe.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useUIStore } from '@/stores/useUIStore'; - -interface EdgeSwipeOptions { - edgeThreshold?: number; - minSwipeDistance?: number; - maxSwipeTime?: number; - enabled?: boolean; -} - -export const useEdgeSwipe = (options: EdgeSwipeOptions = {}) => { - const { - edgeThreshold = 30, - minSwipeDistance = 50, - maxSwipeTime = 300, - enabled = true, - } = options; - - const isMobile = useUIStore((state) => state.isMobile); - const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); - const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); - const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); - const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null); - - useEffect(() => { - if (!enabled || !isMobile) return; - - const handleTouchStart = (e: TouchEvent) => { - const touch = e.touches[0]; - if (!touch) { - touchStartRef.current = null; - return; - } - - const fromLeft = touch.clientX <= edgeThreshold; - - if (fromLeft) { - touchStartRef.current = { - x: touch.clientX, - y: touch.clientY, - time: Date.now(), - }; - } else { - touchStartRef.current = null; - } - }; - - const handleTouchMove = (e: TouchEvent) => { - if (!touchStartRef.current) { - return; - } - - const touch = e.touches[0]; - if (!touch) { - return; - } - - const deltaX = touch.clientX - touchStartRef.current.x; - - if (deltaX > 10) { - e.preventDefault(); - } - }; - - const handleTouchEnd = (e: TouchEvent) => { - if (!touchStartRef.current) return; - - const touch = e.changedTouches[0]; - if (!touch) { - touchStartRef.current = null; - touchEndRef.current = null; - return; - } - - touchEndRef.current = { - x: touch.clientX, - y: touch.clientY, - time: Date.now(), - }; - - const { x: startX, y: startY, time: startTime } = touchStartRef.current; - const { x: endX, y: endY, time: endTime } = touchEndRef.current; - - const deltaX = endX - startX; - const deltaY = endY - startY; - const deltaTime = endTime - startTime; - - const isHorizontal = Math.abs(deltaY) < Math.abs(deltaX); - const isQuick = deltaTime <= maxSwipeTime; - const limitedVertical = Math.abs(deltaY) < minSwipeDistance; - - const isValidLeftSwipe = - deltaX >= minSwipeDistance && isHorizontal && isQuick && limitedVertical; - - if (isValidLeftSwipe && !isSessionSwitcherOpen) { - setSessionSwitcherOpen(true); - } - - touchStartRef.current = null; - touchEndRef.current = null; - }; - - const handleTouchCancel = () => { - touchStartRef.current = null; - touchEndRef.current = null; - }; - - document.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true }); - document.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true }); - document.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true }); - document.addEventListener('touchcancel', handleTouchCancel, { passive: true, capture: true }); - - return () => { - document.removeEventListener('touchstart', handleTouchStart, { capture: true }); - document.removeEventListener('touchmove', handleTouchMove, { capture: true }); - document.removeEventListener('touchend', handleTouchEnd, { capture: true }); - document.removeEventListener('touchcancel', handleTouchCancel, { capture: true }); - }; - }, [ - enabled, - isMobile, - edgeThreshold, - minSwipeDistance, - maxSwipeTime, - setSessionSwitcherOpen, - isSessionSwitcherOpen, - ]); - - return null; -}; diff --git a/packages/ui/src/hooks/useGitmojiList.ts b/packages/ui/src/hooks/useGitmojiList.ts index 30f07efc..5c36e03d 100644 --- a/packages/ui/src/hooks/useGitmojiList.ts +++ b/packages/ui/src/hooks/useGitmojiList.ts @@ -1,6 +1,6 @@ import React from 'react'; -export type GitmojiEntry = { +type GitmojiEntry = { emoji: string; code: string; description: string; diff --git a/packages/ui/src/hooks/useLongPress.ts b/packages/ui/src/hooks/useLongPress.ts deleted file mode 100644 index c883f082..00000000 --- a/packages/ui/src/hooks/useLongPress.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useRef, useCallback, useEffect } from 'react'; - -type LongPressOptions = { - delay?: number; - onLongPress: () => void; - onTap?: () => void; - enableHaptic?: boolean; -}; - -export function useLongPress({ - delay = 500, - onLongPress, - onTap, - enableHaptic = true, -}: LongPressOptions) { - const timerRef = useRef | null>(null); - const isLongPressRef = useRef(false); - const startPosRef = useRef<{ x: number; y: number } | null>(null); - - const clear = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - startPosRef.current = null; - }, []); - - const onPointerDown = useCallback((e: React.PointerEvent | React.TouchEvent) => { - clear(); - isLongPressRef.current = false; - - // Store start position to detect movement - const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY; - startPosRef.current = { x: clientX, y: clientY }; - - timerRef.current = setTimeout(() => { - isLongPressRef.current = true; - if (enableHaptic && typeof navigator !== 'undefined' && navigator.vibrate) { - try { - navigator.vibrate(15); - } catch { - // Ignore vibration errors - } - } - onLongPress(); - }, delay); - }, [clear, delay, onLongPress, enableHaptic]); - - const onPointerMove = useCallback((e: React.PointerEvent | React.TouchEvent) => { - if (!startPosRef.current || !timerRef.current) return; - - const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.PointerEvent).clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.PointerEvent).clientY; - - // If moved more than 10px, cancel long press - const dx = Math.abs(clientX - startPosRef.current.x); - const dy = Math.abs(clientY - startPosRef.current.y); - - if (dx > 10 || dy > 10) { - clear(); - } - }, [clear]); - - const onPointerUp = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - if (!isLongPressRef.current && onTap) { - // Only trigger tap if we didn't drag too far (checked in move) - // and didn't trigger long press - onTap(); - } - } - clear(); - }, [clear, onTap]); - - const onPointerLeave = useCallback(() => { - clear(); - }, [clear]); - - const onContextMenu = useCallback((e: React.MouseEvent) => { - // Prevent default context menu on long press - if (isLongPressRef.current) { - e.preventDefault(); - } - }, []); - - useEffect(() => clear, [clear]); - - return { - onPointerDown, - onPointerMove, - onPointerUp, - onPointerLeave, - onTouchStart: onPointerDown, // Add touch handlers for better mobile support - onTouchMove: onPointerMove, - onTouchEnd: onPointerUp, - onTouchCancel: clear, - onContextMenu, - }; -} diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 626cd348..011d1110 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -63,7 +63,7 @@ const resolveProviderLogoSrc = (providerId: string | null | undefined): string | return remoteResolvedId ? `https://models.dev/logos/${remoteResolvedId}.svg` : null; }; -export const preloadProviderLogo = (providerId: string | null | undefined): void => { +const preloadProviderLogo = (providerId: string | null | undefined): void => { if (typeof Image === 'undefined') return; const src = resolveProviderLogoSrc(providerId); if (!src || PRELOADED_LOGO_SRCS.has(src)) return; diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index c5896075..a34095d8 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -246,102 +246,3 @@ export function useRouter(): void { }; }, [applyRoute, isVSCode, setActiveMainTab, setSettingsDialogOpen]); } - -/** - * Programmatically navigate to a route. - * Can be used from outside React components. - */ -export function navigateToRoute(route: Partial): void { - if (typeof window === 'undefined') { - return; - } - - // Check VS Code context - const win = window as { __VSCODE_CONFIG__?: unknown }; - if (win.__VSCODE_CONFIG__ !== undefined) { - // In VS Code, just apply state changes directly - if (route.sessionId) { - void useSessionUIStore.getState().setCurrentSession(route.sessionId); - } - if (route.settingsPath) { - useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); - useUIStore.getState().setSettingsDialogOpen(true); - } else if (route.tab) { - useUIStore.getState().setActiveMainTab(route.tab); - } - if (route.diffFile) { - useUIStore.getState().navigateToDiff(route.diffFile); - } - return; - } - - // Build URL and navigate - const params = new URLSearchParams(); - - if (route.sessionId) { - params.set('session', route.sessionId); - } - if (route.settingsPath) { - params.set('settings', route.settingsPath); - } else if (route.tab && route.tab !== 'chat') { - if (useUIStore.getState().isSettingsDialogOpen) { - useUIStore.getState().setSettingsDialogOpen(false); - } - params.set('tab', route.tab); - } - if (route.diffFile) { - params.set('file', route.diffFile); - } - - const search = params.toString(); - const url = search ? `${window.location.pathname}?${search}` : window.location.pathname; - - window.history.pushState({ route }, '', url); - - // Also apply to state - if (route.sessionId) { - void useSessionUIStore.getState().setCurrentSession(route.sessionId); - } - if (route.settingsPath) { - useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); - useUIStore.getState().setSettingsDialogOpen(true); - } else if (route.tab) { - useUIStore.getState().setActiveMainTab(route.tab); - } - if (route.diffFile) { - useUIStore.getState().navigateToDiff(route.diffFile); - } -} - -/** - * Get a shareable URL for the current state. - */ -export function getShareableURL(): string { - if (typeof window === 'undefined') { - return '/'; - } - - const sessionState = useSessionUIStore.getState(); - const uiState = useUIStore.getState(); - - const params = new URLSearchParams(); - - if (sessionState.currentSessionId) { - params.set('session', sessionState.currentSessionId); - } - - if (uiState.isSettingsDialogOpen) { - params.set('settings', uiState.settingsPage || 'home'); - } else if (uiState.activeMainTab !== 'chat') { - params.set('tab', uiState.activeMainTab); - } - - if (uiState.activeMainTab === 'diff' && uiState.pendingDiffFile) { - params.set('file', uiState.pendingDiffFile); - } - - const search = params.toString(); - const base = `${window.location.origin}${window.location.pathname}`; - - return search ? `${base}?${search}` : base; -} diff --git a/packages/ui/src/hooks/useRuntimeAPIs.ts b/packages/ui/src/hooks/useRuntimeAPIs.ts index a2c055ca..f200be70 100644 --- a/packages/ui/src/hooks/useRuntimeAPIs.ts +++ b/packages/ui/src/hooks/useRuntimeAPIs.ts @@ -10,7 +10,7 @@ export const useRuntimeAPIs = (): RuntimeAPIs => { return apis; }; -export const useRuntimeAPI = (selector: RuntimeAPISelector): TValue => { +const useRuntimeAPI = (selector: RuntimeAPISelector): TValue => { const apis = useRuntimeAPIs(); return selector(apis); }; diff --git a/packages/ui/src/hooks/useSayTTS.ts b/packages/ui/src/hooks/useSayTTS.ts index ae2d7ca6..c00353b1 100644 --- a/packages/ui/src/hooks/useSayTTS.ts +++ b/packages/ui/src/hooks/useSayTTS.ts @@ -100,7 +100,7 @@ export interface UseSayTTSReturn { unlockAudio: () => Promise; } -export interface SpeakOptions { +interface SpeakOptions { /** Voice to use (defaults to Samantha) */ voice?: string; /** Speech rate in words per minute (defaults to 200) */ diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index ddeaa682..1b7697bd 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -84,7 +84,7 @@ export interface UseServerTTSReturn { unlockAudio: () => Promise; } -export interface SpeakOptions { +interface SpeakOptions { /** Voice to use (defaults to coral) */ voice?: string; /** Model to use (defaults to gpt-4o-mini-tts) */ diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index 53010e2f..a19eda62 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -3,7 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionStatus, useSessionMessages, useSessionPermissions, useSessionQuestions } from '@/sync/sync-context'; // Mirrors OpenCode SessionStatus: busy|retry|idle. -export type SessionActivityPhase = 'idle' | 'busy' | 'retry'; +type SessionActivityPhase = 'idle' | 'busy' | 'retry'; export interface SessionActivityResult { phase: SessionActivityPhase; @@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = { * question indicator takes priority, and the send button must stay available so * the user can supersede the prompt with a new message). */ -export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { +function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { const status = useSessionStatus(sessionId ?? '', directory); const messages = useSessionMessages(sessionId ?? '', directory); const permissions = useSessionPermissions(sessionId ?? '', directory); diff --git a/packages/ui/src/hooks/useSessionAutoCleanup.ts b/packages/ui/src/hooks/useSessionAutoCleanup.ts index 8b5b486e..7ae8878a 100644 --- a/packages/ui/src/hooks/useSessionAutoCleanup.ts +++ b/packages/ui/src/hooks/useSessionAutoCleanup.ts @@ -22,7 +22,7 @@ type BuildAutoDeleteCandidatesOptions = { now?: number; }; -export const buildAutoDeleteCandidates = ({ +const buildAutoDeleteCandidates = ({ sessions, currentSessionId, cutoffDays, diff --git a/packages/ui/src/hooks/useTimelineStaging.ts b/packages/ui/src/hooks/useTimelineStaging.ts deleted file mode 100644 index 8d3f6c68..00000000 --- a/packages/ui/src/hooks/useTimelineStaging.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 - /** Force the current session timeline to render fully now */ - completeNow: () => 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) - - const completeNow = () => { - if (!sessionKey) { - return false - } - - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current) - frameRef.current = null - } - - activeSession.current = "" - completedSessions.current.add(sessionKey) - - const total = messages.length - let changed = false - setStagedCount((previous) => { - if (previous === total) { - return previous - } - changed = true - return total - }) - return changed - } - - 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, completeNow } -} diff --git a/packages/ui/src/hooks/useVisualViewport.test.ts b/packages/ui/src/hooks/useVisualViewport.test.ts deleted file mode 100644 index 4fb01d7f..00000000 --- a/packages/ui/src/hooks/useVisualViewport.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { getVisualViewportState } from './useVisualViewport'; - -const withWindow = (value: { innerHeight: number; visualViewport?: { height: number } | null }, run: () => void) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { - configurable: true, - value, - }); - - try { - run(); - } finally { - if (originalWindow) { - Object.defineProperty(globalThis, 'window', originalWindow); - } else { - Reflect.deleteProperty(globalThis, 'window'); - } - } -}; - -describe('getVisualViewportState', () => { - test('falls back to innerHeight when visualViewport is unavailable', () => { - withWindow({ innerHeight: 812 }, () => { - expect(getVisualViewportState()).toEqual({ height: 812, keyboardHeight: 0 }); - }); - }); - - test('derives keyboard height from visualViewport height', () => { - withWindow({ innerHeight: 812, visualViewport: { height: 512 } }, () => { - expect(getVisualViewportState()).toEqual({ height: 512, keyboardHeight: 300 }); - }); - }); -}); diff --git a/packages/ui/src/hooks/useVisualViewport.ts b/packages/ui/src/hooks/useVisualViewport.ts deleted file mode 100644 index 49b35de1..00000000 --- a/packages/ui/src/hooks/useVisualViewport.ts +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; - -export interface VisualViewportState { - height: number; - keyboardHeight: number; -} - -export const getVisualViewportState = (): VisualViewportState => { - if (typeof window === 'undefined') { - return { height: 0, keyboardHeight: 0 }; - } - - const height = window.visualViewport?.height ?? window.innerHeight; - const keyboardHeight = window.visualViewport - ? Math.max(0, window.innerHeight - height) - : 0; - - return { height, keyboardHeight }; -}; - -export const useVisualViewport = (): VisualViewportState => { - const [state, setState] = React.useState(getVisualViewportState); - - const rafIdRef = React.useRef(null); - - React.useEffect(() => { - if (typeof window === 'undefined') return; - - const visualViewport = window.visualViewport; - - const handleChange = () => { - if (rafIdRef.current !== null) return; - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = null; - const nextState = getVisualViewportState(); - setState((prev) => { - if (prev.height === nextState.height && prev.keyboardHeight === nextState.keyboardHeight) return prev; - return nextState; - }); - }); - }; - - if (visualViewport) { - visualViewport.addEventListener('resize', handleChange); - visualViewport.addEventListener('scroll', handleChange, { passive: true }); - } else { - window.addEventListener('resize', handleChange); - } - handleChange(); - - return () => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - if (visualViewport) { - visualViewport.removeEventListener('resize', handleChange); - visualViewport.removeEventListener('scroll', handleChange); - } else { - window.removeEventListener('resize', handleChange); - } - }; - }, []); - - return state; -}; diff --git a/packages/ui/src/lib/agentColors.ts b/packages/ui/src/lib/agentColors.ts index 81a28704..3a4baf0b 100644 --- a/packages/ui/src/lib/agentColors.ts +++ b/packages/ui/src/lib/agentColors.ts @@ -30,8 +30,4 @@ export function getAgentColor(agentName: string | undefined) { const paletteIndex = 1 + (Math.abs(hash) % (AGENT_COLOR_PALETTE.length - 1)); return AGENT_COLOR_PALETTE[paletteIndex]; -} - -export function getAgentColorPalette() { - return AGENT_COLOR_PALETTE; } \ No newline at end of file diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9b4021b5..c8bfd37e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1,9 +1,9 @@ import type { WorktreeMetadata } from '@/types/worktree'; import type { DraftStarterRef } from '@/lib/draftStarters'; -export type RuntimePlatform = 'web' | 'desktop' | 'vscode'; +type RuntimePlatform = 'web' | 'desktop' | 'vscode'; -export interface RuntimeDescriptor { +interface RuntimeDescriptor { platform: RuntimePlatform; isDesktop: boolean; @@ -13,24 +13,18 @@ export interface RuntimeDescriptor { label?: string; } -export interface ApiError { - message: string; - code?: string; - cause?: unknown; -} - -export interface Subscription { +interface Subscription { close: () => void; } -export interface RetryPolicy { +interface RetryPolicy { maxRetries: number; initialDelayMs: number; maxDelayMs: number; } -export interface TerminalTransportCapability { +interface TerminalTransportCapability { preferred?: 'ws' | 'http' | 'sse'; transports?: Array<'ws' | 'http' | 'sse'>; ws?: { @@ -99,7 +93,7 @@ export interface TerminalAPI { forceKill?(options: ForceKillOptions): Promise; } -export interface GitStatusFile { +interface GitStatusFile { path: string; index: string; working_dir: string; @@ -181,7 +175,7 @@ export interface GitBranch { branches: Record; } -export interface GitCommitSummary { +interface GitCommitSummary { changes: number; insertions: number; deletions: number; @@ -241,29 +235,18 @@ export interface CheckoutCommitResponse { success: boolean; } -export interface CherryPickRequest { - hash: string; -} export interface CherryPickResponse { success: boolean; conflict?: boolean; conflictFiles?: string[]; } -export interface RevertCommitRequest { - hash: string; -} export interface RevertCommitResponse { success: boolean; conflict?: boolean; conflictFiles?: string[]; } -export interface ResetToCommitRequest { - hash: string; - mode: 'soft' | 'mixed' | 'hard'; - force?: boolean; -} export interface ResetToCommitResponse { success: boolean; } @@ -456,7 +439,7 @@ export interface GeneratedPullRequestDescription { body: string; } -export interface GitWorktreeAPI { +interface GitWorktreeAPI { list(directory: string): Promise; validate?(directory: string, payload: CreateGitWorktreePayload): Promise; bootstrapStatus?(directory: string): Promise; @@ -591,11 +574,11 @@ export interface CommandExecResult { error?: string; } -export interface ListDirectoryOptions { +interface ListDirectoryOptions { respectGitignore?: boolean; } -export interface FileReadOptions { +interface FileReadOptions { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean; @@ -707,7 +690,7 @@ export interface DirectoryPermissionRequest { path: string; } -export interface DirectoryPermissionResult { +interface DirectoryPermissionResult { success: boolean; path?: string; error?: string; @@ -740,7 +723,7 @@ export interface NotificationsAPI { canNotify?: () => boolean | Promise; } -export interface DiagnosticsAPI { +interface DiagnosticsAPI { downloadLogs(): Promise<{ fileName: string; content: string }>; } @@ -796,13 +779,13 @@ export type GitHubUserSummary = { email?: string; }; -export type GitHubRepoRef = { +type GitHubRepoRef = { owner: string; repo: string; url: string; }; -export type GitHubChecksSummary = { +type GitHubChecksSummary = { state: 'success' | 'failure' | 'pending' | 'unknown'; total: number; success: number; @@ -865,7 +848,7 @@ export type GitHubPullRequest = { mergeableState?: string | null; }; -export type GitHubPullRequestHeadRepo = { +type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; @@ -883,7 +866,7 @@ export type GitHubPullRequestSummary = GitHubPullRequest & { sourceRepo?: (GitHubRepoSelector & { source: string }) | null; }; -export type GitHubPullRequestFile = { +type GitHubPullRequestFile = { filename: string; status?: string; additions?: number; @@ -892,7 +875,7 @@ export type GitHubPullRequestFile = { patch?: string; }; -export type GitHubPullRequestReviewComment = { +type GitHubPullRequestReviewComment = { id: number; url: string; body: string; @@ -977,7 +960,7 @@ export type GitHubPullRequestMergeResult = { message?: string; }; -export type GitHubIssueLabel = { +type GitHubIssueLabel = { name: string; color?: string; }; @@ -1052,7 +1035,7 @@ export type GitHubAuthStatus = { } | null; }; -export type GitHubAuthAccount = { +type GitHubAuthAccount = { id: string; user: GitHubUserSummary; scope?: string; @@ -1156,9 +1139,9 @@ export type RuntimeAPISelector = (apis: RuntimeAPIs) => TValue; // ============== Skills Catalog Types ============== -export type SkillsCatalogSourceId = string; +type SkillsCatalogSourceId = string; -export type SkillsCatalogSourceType = 'github' | 'clawdhub'; +type SkillsCatalogSourceType = 'github' | 'clawdhub'; export interface SkillsCatalogSource { id: SkillsCatalogSourceId; @@ -1169,13 +1152,13 @@ export interface SkillsCatalogSource { sourceType?: SkillsCatalogSourceType; } -export interface SkillsCatalogItemInstalledBadge { +interface SkillsCatalogItemInstalledBadge { isInstalled: boolean; scope?: 'user' | 'project'; source?: 'opencode' | 'agents' | 'claude'; } -export interface ClawdHubSkillMetadata { +interface ClawdHubSkillMetadata { slug: string; version: string; displayName?: string; @@ -1224,7 +1207,7 @@ export interface SkillsRepoScanRequest { gitIdentityId?: string; } -export type SkillsRepoScanError = +type SkillsRepoScanError = | { kind: 'authRequired'; message: string; sshOnly: true; identities?: Array<{ id: string; name: string }> } | { kind: 'invalidSource'; message: string } | { kind: 'gitUnavailable'; message: string } @@ -1237,7 +1220,7 @@ export interface SkillsRepoScanResponse { error?: SkillsRepoScanError; } -export interface SkillsInstallSelection { +interface SkillsInstallSelection { skillDir: string; /** ClawdHub-specific metadata for installation */ clawdhub?: { diff --git a/packages/ui/src/lib/appearancePersistence.ts b/packages/ui/src/lib/appearancePersistence.ts index 41952204..e90cfd57 100644 --- a/packages/ui/src/lib/appearancePersistence.ts +++ b/packages/ui/src/lib/appearancePersistence.ts @@ -42,19 +42,6 @@ const extractRawAppearance = (data: unknown): RawAppearancePayload | null => { return payload; }; -export const saveAppearancePreferences = (preferences: AppearancePreferences): boolean => { - if (typeof window === 'undefined') { - return false; - } - - try { - localStorage.setItem('appearance-preferences', JSON.stringify(preferences)); - return true; - } catch { - return false; - } -}; - export const applyAppearancePreferences = (preferences: AppearancePreferences): void => { const store = useUIStore.getState(); diff --git a/packages/ui/src/lib/codeTheme.ts b/packages/ui/src/lib/codeTheme.ts deleted file mode 100644 index 5b7eb0a3..00000000 --- a/packages/ui/src/lib/codeTheme.ts +++ /dev/null @@ -1,352 +0,0 @@ - -export const defaultCodeDark = { - 'code[class*="language-"]': { - color: '#cdccc3', - background: 'transparent', - fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - fontSize: '1em', - textAlign: 'left' as const, - whiteSpace: 'pre', - wordSpacing: 'normal', - wordBreak: 'normal' as const, - wordWrap: 'normal' as const, - lineHeight: '1.5', - MozTabSize: '4', - OTabSize: '4', - tabSize: '4', - WebkitHyphens: 'none' as const, - MozHyphens: 'none' as const, - msHyphens: 'none' as const, - hyphens: 'none' as const, - }, - 'pre[class*="language-"]': { - color: '#cdccc3', - background: 'transparent', - fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - fontSize: '1em', - textAlign: 'left' as const, - whiteSpace: 'pre', - wordSpacing: 'normal', - wordBreak: 'normal' as const, - wordWrap: 'normal' as const, - lineHeight: '1.5', - MozTabSize: '4', - OTabSize: '4', - tabSize: '4', - WebkitHyphens: 'none' as const, - MozHyphens: 'none' as const, - msHyphens: 'none' as const, - hyphens: 'none' as const, - padding: '1em', - margin: '0', - overflow: 'auto', - }, - comment: { - color: '#6b6964', - fontStyle: 'italic', - }, - prolog: { - color: '#6b6964', - }, - doctype: { - color: '#6b6964', - }, - cdata: { - color: '#6b6964', - }, - punctuation: { - color: '#6b6963', - }, - property: { - color: '#d29470', - }, - tag: { - color: '#d8886d', - }, - boolean: { - color: '#d39373', - }, - number: { - color: '#d39373', - }, - constant: { - color: '#6cacd6', - }, - symbol: { - color: '#6cacd6', - }, - deleted: { - color: '#d98678', - }, - selector: { - color: '#81af6c', - }, - 'attr-name': { - color: '#c2974d', - }, - string: { - color: '#81af6c', - }, - char: { - color: '#81af6c', - }, - builtin: { - color: '#5aa9d9', - }, - inserted: { - color: '#81af6c', - }, - operator: { - color: '#d29470', - }, - entity: { - color: '#edb449', - cursor: 'help', - }, - url: { - color: '#5aa9d9', - }, - '.language-css .token.string': { - color: '#81af6c', - }, - '.style .token.string': { - color: '#81af6c', - }, - variable: { - color: '#d29470', - }, - atrule: { - color: '#c2974d', - }, - 'attr-value': { - color: '#81af6c', - }, - function: { - color: '#5aa9d9', - }, - 'class-name': { - color: '#c2974d', - }, - keyword: { - color: '#d8886d', - }, - regex: { - color: '#81af6c', - }, - important: { - color: '#d8886d', - fontWeight: 'bold', - }, - bold: { - fontWeight: 'bold', - }, - italic: { - fontStyle: 'italic', - }, - namespace: { - opacity: 0.7, - }, - - title: { - color: '#edb449', - fontWeight: 'bold', - }, - 'code-block': { - color: '#81af6c', - }, - 'code-snippet': { - color: '#81af6c', - }, - list: { - color: '#d29470', - }, - hr: { - color: '#6b6963', - }, - table: { - color: '#5aa9d9', - }, - blockquote: { - color: '#6b6964', - fontStyle: 'italic', - }, - strike: { - textDecoration: 'line-through', - }, -}; - -export const defaultCodeLight = { - 'code[class*="language-"]': { - color: '#403e3c', - background: 'transparent', - fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - fontSize: '1em', - textAlign: 'left' as const, - whiteSpace: 'pre', - wordSpacing: 'normal', - wordBreak: 'normal' as const, - wordWrap: 'normal' as const, - lineHeight: '1.5', - MozTabSize: '4', - OTabSize: '4', - tabSize: '4', - WebkitHyphens: 'none' as const, - MozHyphens: 'none' as const, - msHyphens: 'none' as const, - hyphens: 'none' as const, - }, - 'pre[class*="language-"]': { - color: '#403e3c', - background: 'transparent', - fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - fontSize: '1em', - textAlign: 'left' as const, - whiteSpace: 'pre', - wordSpacing: 'normal', - wordBreak: 'normal' as const, - wordWrap: 'normal' as const, - lineHeight: '1.5', - MozTabSize: '4', - OTabSize: '4', - tabSize: '4', - WebkitHyphens: 'none' as const, - MozHyphens: 'none' as const, - msHyphens: 'none' as const, - hyphens: 'none' as const, - padding: '1em', - margin: '0', - overflow: 'auto', - }, - comment: { - color: '#7a756a', - fontStyle: 'italic', - }, - prolog: { - color: '#7a756a', - }, - doctype: { - color: '#7a756a', - }, - cdata: { - color: '#7a756a', - }, - punctuation: { - color: '#6b6963', - }, - property: { - color: '#c07845', - }, - tag: { - color: '#b96f55', - }, - boolean: { - color: '#b97659', - }, - number: { - color: '#b97659', - }, - constant: { - color: '#4791ba', - }, - symbol: { - color: '#4791ba', - }, - deleted: { - color: '#c15748', - }, - selector: { - color: '#6a9354', - }, - 'attr-name': { - color: '#9f7d3e', - }, - string: { - color: '#6a9354', - }, - char: { - color: '#6a9354', - }, - builtin: { - color: '#4791ba', - }, - inserted: { - color: '#6a9354', - }, - operator: { - color: '#c07845', - }, - entity: { - color: '#d09930', - cursor: 'help', - }, - url: { - color: '#4791ba', - }, - '.language-css .token.string': { - color: '#6a9354', - }, - '.style .token.string': { - color: '#6a9354', - }, - variable: { - color: '#c07845', - }, - atrule: { - color: '#9f7d3e', - }, - 'attr-value': { - color: '#6a9354', - }, - function: { - color: '#4791ba', - }, - 'class-name': { - color: '#9f7d3e', - }, - keyword: { - color: '#b96f55', - }, - regex: { - color: '#6a9354', - }, - important: { - color: '#b96f55', - fontWeight: 'bold', - }, - bold: { - fontWeight: 'bold', - }, - italic: { - fontStyle: 'italic', - }, - namespace: { - opacity: 0.7, - }, - - title: { - color: '#d09930', - fontWeight: 'bold', - }, - 'code-block': { - color: '#6a9354', - }, - 'code-snippet': { - color: '#6a9354', - }, - list: { - color: '#c07845', - }, - hr: { - color: '#6b6963', - }, - table: { - color: '#4791ba', - }, - blockquote: { - color: '#7a756a', - fontStyle: 'italic', - }, - strike: { - textDecoration: 'line-through', - }, -}; \ No newline at end of file diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index f4793e46..9bdcdcba 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -5,9 +5,10 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -export type AssistantNotificationPayload = { - title?: string; - body?: string; +type ManagedRemoteTunnelPreset = { + id: string; + name: string; + hostname: string; }; export type UpdateInfo = { @@ -35,12 +36,6 @@ export type SkillCatalogConfig = { gitIdentityId?: string; }; -export type ManagedRemoteTunnelPreset = { - id: string; - name: string; - hostname: string; -}; - export type DesktopSettings = { themeId?: string; useSystemTheme?: boolean; @@ -526,28 +521,6 @@ export const stopAccessingDirectory = async ( return { success: true }; }; -export const sendAssistantCompletionNotification = async ( - payload?: AssistantNotificationPayload -): Promise => { - if (hasDesktopInvoke()) { - try { - await invokeDesktop('desktop_notify', { - payload: { - title: payload?.title, - body: payload?.body, - tag: 'openchamber-agent-complete', - }, - }); - return true; - } catch (error) { - console.warn('Failed to send assistant completion notification', error); - return false; - } - } - - return false; -}; - export const checkForDesktopUpdates = async (): Promise => { if (!hasDesktopInvoke()) { return null; @@ -787,58 +760,6 @@ export const openDesktopFileInApp = async ( } }; -export const filterInstalledDesktopApps = async (apps: string[]): Promise => { - if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { - return []; - } - - const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; - if (candidate.length === 0) { - return []; - } - - try { - const result = await invokeDesktop('desktop_filter_installed_apps', { - apps: candidate, - }); - return Array.isArray(result) ? result.filter((value) => typeof value === 'string') : []; - } catch (error) { - console.warn('Failed to check installed apps', error); - return []; - } -}; - -export const fetchDesktopAppIcons = async (apps: string[]): Promise> => { - if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { - return {}; - } - - const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; - if (candidate.length === 0) { - return {}; - } - - try { - const result = await invokeDesktop('desktop_fetch_app_icons', { - apps: candidate, - }); - if (!Array.isArray(result)) { - return {}; - } - const map: Record = {}; - for (const entry of result) { - if (!entry || typeof entry !== 'object') continue; - const candidateEntry = entry as { app?: unknown; data_url?: unknown }; - if (typeof candidateEntry.app !== 'string' || typeof candidateEntry.data_url !== 'string') continue; - map[candidateEntry.app] = candidateEntry.data_url; - } - return map; - } catch (error) { - console.warn('Failed to fetch installed app icons', error); - return {}; - } -}; - export type InstalledDesktopAppInfo = { name: string; iconDataUrl?: string | null; @@ -898,16 +819,3 @@ export const fetchDesktopInstalledApps = async ( } }; -export const clearDesktopCache = async (): Promise => { - if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) { - return false; - } - - try { - await invokeDesktop('desktop_clear_cache'); - return true; - } catch (error) { - console.warn('Failed to clear cache', error); - return false; - } -}; diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 5b3be37f..9f1a7d29 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -24,27 +24,6 @@ export const startDesktopWindowDrag = async (): Promise => { } }; -export const isDesktopWindowFullscreen = async (): Promise => { - if (!isDesktopShell()) { - return false; - } - - try { - return Boolean(await invokeDesktopCommand('desktop_is_window_fullscreen')); - } catch { - return false; - } -}; - -export const onDesktopWindowResized = (handler: () => void): (() => void) => { - if (typeof window === 'undefined') { - return () => {}; - } - - window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); -}; - export const setDesktopWindowTitle = async (title: string): Promise => { if (!isDesktopShell()) { return; @@ -84,25 +63,3 @@ export const getDesktopAppVersion = async (): Promise => { return null; } }; - -export const readDesktopFile = async ( - path: string, -): Promise<{ mime: string; base64: string; size?: number }> => { - return invokeDesktopCommand('desktop_read_file', { path }); -}; - -export const readDesktopFileAsDataUrl = async (path: string): Promise => { - const result = await readDesktopFile(path); - return `data:${result.mime || 'application/octet-stream'};base64,${result.base64}`; -}; - -export const listenDesktopNativeDragDrop = async ( - handler: (event: unknown) => void, -): Promise<(() => void) | null> => { - if (!isDesktopShell() || typeof window === 'undefined') { - return null; - } - - void handler; - return null; -}; diff --git a/packages/ui/src/lib/desktopSsh.ts b/packages/ui/src/lib/desktopSsh.ts index a05f598f..ed2abee0 100644 --- a/packages/ui/src/lib/desktopSsh.ts +++ b/packages/ui/src/lib/desktopSsh.ts @@ -9,11 +9,11 @@ type DesktopBridgeGlobal = { ) => Promise<() => void>; }; -export type DesktopSshRemoteMode = 'managed' | 'external'; -export type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle'; -export type DesktopSshSecretStore = 'never' | 'settings'; +type DesktopSshRemoteMode = 'managed' | 'external'; +type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle'; +type DesktopSshSecretStore = 'never' | 'settings'; -export type DesktopSshStoredSecret = { +type DesktopSshStoredSecret = { enabled: boolean; value?: string; store: DesktopSshSecretStore; @@ -62,7 +62,7 @@ export type DesktopSshInstancesConfig = { instances: DesktopSshInstance[]; }; -export type DesktopSshPhase = +type DesktopSshPhase = | 'idle' | 'config_resolved' | 'auth_check' diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index 3236305b..ccd98952 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -1,7 +1,7 @@ import React from 'react'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; -export type DeviceType = 'desktop' | 'mobile' | 'tablet'; +type DeviceType = 'desktop' | 'mobile' | 'tablet'; export interface DeviceInfo { isMobile: boolean; @@ -14,13 +14,7 @@ export interface DeviceInfo { hasTouchOnlyPointer: boolean; } -export const CSS_DEVICE_VARIABLES = { - IS_MOBILE: 'var(--is-mobile)', - DEVICE_TYPE: 'var(--device-type)', - HAS_TOUCH_INPUT: 'var(--has-touch-input)', -} as const; - -export const BREAKPOINTS = { +const BREAKPOINTS = { xs: 0, sm: 640, md: 768, @@ -291,7 +285,7 @@ export function isMobileDeviceViaCSS(): boolean { return isMobileValue === '1' || isMobileValue === 'true'; } -export const isStandalonePwaRuntime = (): boolean => { +const isStandalonePwaRuntime = (): boolean => { if (typeof window === 'undefined') return false; const standaloneNavigator = navigator as Navigator & { standalone?: boolean }; @@ -302,7 +296,7 @@ export const isStandalonePwaRuntime = (): boolean => { ); }; -export const isTabletStandalonePwaRuntime = (): boolean => { +const isTabletStandalonePwaRuntime = (): boolean => { if (typeof window === 'undefined' || isDesktopShell()) return false; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 263ec1c7..304d6f0a 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -10,23 +10,6 @@ const PATCH_DIFF_CACHE_LIMIT = 64; const DEFAULT_PATCH_CONTEXT_LINES = 3; const patchFileDiffCache = new Map(); -export const fileDiffFromContent = ( - file: string, - before: string, - after: string, - contextLines = DEFAULT_PATCH_CONTEXT_LINES -): FileDiffMetadata => { - if (!before && !after) { - return emptyFileDiff(file); - } - - return parseDiffFromFile( - { name: file, contents: before }, - { name: file, contents: after }, - { context: contextLines }, - ); -}; - export const fileDiffFromPatch = ( file: string, patch: string, diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts index bca1492d..47b9c116 100644 --- a/packages/ui/src/lib/directoryShowHidden.ts +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -20,7 +20,7 @@ const readStoredShowHidden = (): boolean => { } }; -export const notifyDirectoryShowHiddenChanged = () => { +const notifyDirectoryShowHiddenChanged = () => { if (typeof window === 'undefined') { return; } @@ -67,5 +67,3 @@ export const useDirectoryShowHidden = (): boolean => { return showHidden; }; - -export const DIRECTORY_SHOW_HIDDEN_STORAGE_KEY = SHOW_HIDDEN_STORAGE_KEY; diff --git a/packages/ui/src/lib/draftStarters.ts b/packages/ui/src/lib/draftStarters.ts index e1ae732a..f84a2525 100644 --- a/packages/ui/src/lib/draftStarters.ts +++ b/packages/ui/src/lib/draftStarters.ts @@ -34,8 +34,6 @@ export const BUILTIN_STARTERS: readonly BuiltInStarter[] = [ const BUILTIN_BY_NAME = new Map(BUILTIN_STARTERS.map((s) => [s.name, s])); export const getBuiltInStarter = (name: string): BuiltInStarter | undefined => BUILTIN_BY_NAME.get(name); -export const isBuiltInStarter = (ref: DraftStarterRef): boolean => - ref.type === 'command' && BUILTIN_BY_NAME.has(ref.name); // Default global starter set (used until the user customizes the global list). export const DEFAULT_GLOBAL_STARTERS: readonly DraftStarterRef[] = BUILTIN_STARTERS.map((s) => ({ diff --git a/packages/ui/src/lib/execCommands.ts b/packages/ui/src/lib/execCommands.ts deleted file mode 100644 index 4ad67de0..00000000 --- a/packages/ui/src/lib/execCommands.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { CommandExecResult, FilesAPI } from '@/lib/api/types'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { runtimeFetch } from '@/lib/runtime-fetch'; - -type ExecResult = { success: boolean; results: CommandExecResult[] }; - -const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api'; - -const getBaseUrl = (): string => { - if (typeof DEFAULT_BASE_URL === 'string' && DEFAULT_BASE_URL.startsWith('/')) { - return DEFAULT_BASE_URL; - } - return DEFAULT_BASE_URL; -}; - -function getRuntimeFilesAPI(): FilesAPI | null { - const apis = getRegisteredRuntimeAPIs(); - if (apis?.files) { - return apis.files; - } - return null; -} - -export async function execCommands(commands: string[], cwd: string): Promise { - const runtimeFiles = getRuntimeFilesAPI(); - if (runtimeFiles?.execCommands) { - return runtimeFiles.execCommands(commands, cwd); - } - - const response = await runtimeFetch(`${getBaseUrl()}/fs/exec`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ commands, cwd, background: false }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error((error as { error?: string }).error || 'Command exec failed'); - } - - const payload = (await response.json().catch(() => null)) as - | { success?: boolean; results?: CommandExecResult[] } - | null; - - return { - success: Boolean(payload?.success), - results: Array.isArray(payload?.results) ? payload!.results! : [], - }; -} - -export async function execCommand(command: string, cwd: string): Promise { - const result = await execCommands([command], cwd); - const first = result.results[0]; - if (!first) { - return { command, success: result.success }; - } - return first; -} diff --git a/packages/ui/src/lib/fileTypeIcons.ts b/packages/ui/src/lib/fileTypeIcons.ts index 6217dc2b..b60813e6 100644 --- a/packages/ui/src/lib/fileTypeIcons.ts +++ b/packages/ui/src/lib/fileTypeIcons.ts @@ -245,5 +245,3 @@ export const getFileTypeIconHref = ( const iconName = selectVariantIconName(baseIconName, options?.themeVariant || 'dark'); return `#${iconName}`; }; - -export const getFileTypeIconUrl = getFileTypeIconHref; diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts index cceea748..416d667e 100644 --- a/packages/ui/src/lib/filesViewShowGitignored.ts +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -18,7 +18,7 @@ const readStoredShowGitignored = (): boolean => { } }; -export const notifyFilesViewShowGitignoredChanged = () => { +const notifyFilesViewShowGitignoredChanged = () => { if (typeof window === 'undefined') { return; } @@ -65,5 +65,3 @@ export const useFilesViewShowGitignored = (): boolean => { return showGitignored; }; - -export const FILES_VIEW_SHOW_GITIGNORED_STORAGE_KEY = SHOW_GITIGNORED_STORAGE_KEY; diff --git a/packages/ui/src/lib/git/branchNameGenerator.ts b/packages/ui/src/lib/git/branchNameGenerator.ts index bedce8a7..c45fa18e 100644 --- a/packages/ui/src/lib/git/branchNameGenerator.ts +++ b/packages/ui/src/lib/git/branchNameGenerator.ts @@ -3,8 +3,6 @@ * Uses Ubuntu-style adjective-noun word pairs for memorable, collision-resistant naming. */ -import { getGitBranches } from '@/lib/gitApi'; - const ADJECTIVES = [ 'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic', 'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen', @@ -41,36 +39,3 @@ export function generateBranchName(prefix?: string): string { } return slug; } - -/** - * Generate a unique branch name that doesn't conflict with existing branches. - * @param projectDirectory - Project directory to check for existing branches - * @param prefix - Optional branch prefix - * @param maxAttempts - Maximum attempts to generate a unique name (default: 10) - * @returns Unique branch name, or null if all attempts failed - */ -export async function generateUniqueBranchName( - projectDirectory: string, - prefix?: string, - maxAttempts: number = 10 -): Promise { - let existingBranches: Set; - - try { - const branches = await getGitBranches(projectDirectory); - existingBranches = new Set(branches?.all ?? []); - } catch { - // If we can't get branches, just generate without checking - return generateBranchName(prefix); - } - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const candidate = generateBranchName(prefix); - if (!existingBranches.has(candidate)) { - return candidate; - } - } - - // All attempts exhausted, return null - return null; -} diff --git a/packages/ui/src/lib/i18n/index.ts b/packages/ui/src/lib/i18n/index.ts index c08bae28..29e7f073 100644 --- a/packages/ui/src/lib/i18n/index.ts +++ b/packages/ui/src/lib/i18n/index.ts @@ -1,6 +1,6 @@ export { I18nProvider } from './context'; export { getBootstrapMessages, readStoredLocaleForBootstrap } from './bootstrap'; -export { getCurrentIntlLocale, getIntlLocale } from './intl'; +export { getCurrentIntlLocale } from './intl'; export { useI18n } from './useI18n'; export { formatMessage, initializeLocale, useI18nStore } from './store'; export type { I18nKey, I18nParams, Locale } from './store'; diff --git a/packages/ui/src/lib/i18n/intl.ts b/packages/ui/src/lib/i18n/intl.ts index df7af7de..00080675 100644 --- a/packages/ui/src/lib/i18n/intl.ts +++ b/packages/ui/src/lib/i18n/intl.ts @@ -13,6 +13,6 @@ const INTL_LOCALE_BY_LOCALE: Record = { pl: 'pl-PL', }; -export const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US'; +const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US'; export const getCurrentIntlLocale = (): string => getIntlLocale(useI18nStore.getState().locale); diff --git a/packages/ui/src/lib/i18n/runtime.ts b/packages/ui/src/lib/i18n/runtime.ts index a85a5af0..c2a1c4c0 100644 --- a/packages/ui/src/lib/i18n/runtime.ts +++ b/packages/ui/src/lib/i18n/runtime.ts @@ -61,7 +61,7 @@ export function normalizeLocale(value: string | undefined | null): Locale { return DEFAULT_LOCALE; } -export function readStoredLocale(): Locale | undefined { +function readStoredLocale(): Locale | undefined { if (typeof window === 'undefined') { return undefined; } diff --git a/packages/ui/src/lib/jsonTreeUtils.ts b/packages/ui/src/lib/jsonTreeUtils.ts index 64849671..06f09e5e 100644 --- a/packages/ui/src/lib/jsonTreeUtils.ts +++ b/packages/ui/src/lib/jsonTreeUtils.ts @@ -3,7 +3,7 @@ * Provides parsing, tree building, flattening, and path utilities. */ -export type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; +type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; export interface JsonTreeNode { id: string; @@ -44,7 +44,7 @@ function getType(value: unknown): JsonTreeNodeType { return 'null'; } -export function getNodePath(pathSegments: string[]): string { +function getNodePath(pathSegments: string[]): string { if (pathSegments.length === 0) return 'root'; let result = 'root'; for (const segment of pathSegments) { @@ -57,38 +57,6 @@ export function getNodePath(pathSegments: string[]): string { return result; } -export function parseNodePath(pathKey: string): string[] { - if (pathKey === 'root' || pathKey === '') return []; - const withoutRoot = pathKey.startsWith('root.') ? pathKey.slice(5) : pathKey.startsWith('root[') ? pathKey.slice(4) : pathKey; - const segments: string[] = []; - let current = ''; - let i = 0; - while (i < withoutRoot.length) { - const ch = withoutRoot[i]; - if (ch === '.') { - if (current) segments.push(current); - current = ''; - i++; - } else if (ch === '[') { - if (current) segments.push(current); - current = ''; - i++; - let bracket = ''; - while (i < withoutRoot.length && withoutRoot[i] !== ']') { - bracket += withoutRoot[i]; - i++; - } - segments.push(bracket); - i++; - } else { - current += ch; - i++; - } - } - if (current) segments.push(current); - return segments; -} - let nodeCount = 0; function buildTreeNode( @@ -208,15 +176,3 @@ export function getExpandableIdsAboveDepth(root: JsonTreeNode | null, maxDepth: return ids; } -export function isJsonParseable(text: string): boolean { - if (!text || typeof text !== 'string') return false; - const trimmed = text.trim(); - if (trimmed.length < 2) return false; - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false; - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } -} diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 60538697..6d12c6e0 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -64,7 +64,7 @@ export interface MagicPromptOverridesPayload { const API_ENDPOINT = '/api/magic-prompts'; -export const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ +const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ { id: 'git.commit.generate.visible', title: 'Commit Generation Visible Prompt', @@ -951,11 +951,6 @@ export const fetchMagicPromptOverrides = async (): Promise { - cachedOverrides = null; - inFlightOverridesRequest = null; -}; - export const getMagicPromptDefinition = (id: MagicPromptId): MagicPromptDefinition => { const definition = MAGIC_PROMPT_DEFINITION_BY_ID.get(id); if (!definition) { @@ -968,7 +963,7 @@ export const getDefaultMagicPromptTemplate = (id: MagicPromptId): string => { return getMagicPromptDefinition(id).template; }; -export const getEffectiveMagicPromptTemplate = async (id: MagicPromptId): Promise => { +const getEffectiveMagicPromptTemplate = async (id: MagicPromptId): Promise => { const overrides = await fetchMagicPromptOverrides().catch((): Record => ({})); const override = overrides[id]; if (typeof override === 'string') { diff --git a/packages/ui/src/lib/messageCompletion.ts b/packages/ui/src/lib/messageCompletion.ts index 1682d9f2..e4fa05ec 100644 --- a/packages/ui/src/lib/messageCompletion.ts +++ b/packages/ui/src/lib/messageCompletion.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Part } from "@opencode-ai/sdk/v2"; -import { isFullySyntheticMessage } from "@/lib/messages/synthetic"; -export interface MessageInfo { +interface MessageInfo { id: string; role: string; time?: { @@ -18,92 +17,3 @@ export interface MessageRecord { info: MessageInfo & Record; parts: Part[]; } - -export function isMessageComplete(messageInfo: MessageInfo, parts: Part[] = []): boolean { - if (isFullySyntheticMessage(parts)) { - return true; - } - - const timeInfo = messageInfo?.time ?? {}; - const completedAt = typeof timeInfo?.completed === 'number' ? timeInfo.completed : undefined; - const messageStatus = messageInfo?.status; - - const hasStopFinish = messageInfo.finish === 'stop'; - - const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed'; - if (!hasCompletedFlag || !hasStopFinish) { - return false; - } - - const hasActiveTools = parts.some((part) => { - switch (part.type) { - case 'reasoning': { - const time = (part as any)?.time; - return !time || typeof time.end === 'undefined'; - } - case 'tool': { - const status = (part as any)?.state?.status; - return status === 'running' || status === 'pending'; - } - default: - return false; - } - }); - - return !hasActiveTools; -} - -export function getLatestAssistantMessageId(messages: MessageRecord[]): string | null { - const assistantMessages = messages - .filter(msg => msg.info.role === 'assistant' && !isFullySyntheticMessage(msg.parts)) - .sort((a, b) => (a.info.id || "").localeCompare(b.info.id || "")); - - return assistantMessages.length > 0 - ? assistantMessages[assistantMessages.length - 1].info.id - : null; -} - -export function hasAnimatingWork(messages: MessageRecord[]): boolean { - if (messages.length === 0) { - return false; - } - - for (const message of messages) { - if (message.info.role !== 'assistant') { - continue; - } - - if (isFullySyntheticMessage(message.parts)) { - continue; - } - - if (!isMessageComplete(message.info, message.parts)) { - return true; - } - } - - return false; -} - -export function shouldContinueStreaming( - messages: MessageRecord[], - currentStreamingId: string | null -): boolean { - const latestId = getLatestAssistantMessageId(messages); - if (!latestId) { - return false; - } - - if (currentStreamingId && currentStreamingId !== latestId) { - return true; - } - - const latestMessage = messages.find( - (msg) => msg.info.id === latestId && !isFullySyntheticMessage(msg.parts) - ); - if (!latestMessage) { - return false; - } - - return !isMessageComplete(latestMessage.info, latestMessage.parts); -} diff --git a/packages/ui/src/lib/messageCursorPersistence.ts b/packages/ui/src/lib/messageCursorPersistence.ts deleted file mode 100644 index ee5b91af..00000000 --- a/packages/ui/src/lib/messageCursorPersistence.ts +++ /dev/null @@ -1,176 +0,0 @@ -const DB_NAME = 'openchamber-message-cursors'; -const STORE_NAME = 'cursors'; -const DB_VERSION = 1; -const FALLBACK_KEY = 'openchamber.messageCursors'; - -type CursorRecord = { - messageId: string; - completedAt: number; -}; - -const isBrowser = () => typeof window !== 'undefined'; - -const hasIndexedDbSupport = () => { - return isBrowser() && typeof indexedDB !== 'undefined'; -}; - -const openDatabase = (): Promise => { - if (!hasIndexedDbSupport()) { - return Promise.reject(new Error('IndexedDB not supported')); - } - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - - request.onerror = () => { - reject(request.error ?? new Error('Failed to open IndexedDB')); - }; - - request.onsuccess = () => { - resolve(request.result); - }; - }); -}; - -let dbPromise: Promise | null = null; - -const getDatabase = (): Promise => { - if (!dbPromise) { - dbPromise = openDatabase() - .then((db) => { - db.onclose = () => { - dbPromise = null; - }; - db.onversionchange = () => { - db.close(); - }; - return db; - }) - .catch((error: unknown) => { - dbPromise = null; - throw error; - }); - } - return dbPromise; -}; - -const readFallback = (): Record => { - if (!isBrowser()) { - return {}; - } - - try { - const raw = window.localStorage.getItem(FALLBACK_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -}; - -const writeFallback = (map: Record) => { - if (!isBrowser()) { - return; - } - - try { - window.localStorage.setItem(FALLBACK_KEY, JSON.stringify(map)); - } catch { /* ignored */ } -}; - -export const saveSessionCursor = async ( - sessionId: string, - messageId: string, - completedAt: number -) => { - if (!sessionId || !messageId) { - return; - } - - if (hasIndexedDbSupport()) { - try { - const db = await getDatabase(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - store.put({ messageId, completedAt }, sessionId); - - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error ?? new Error('Cursor write failed')); - tx.onabort = () => reject(tx.error ?? new Error('Cursor write aborted')); - }); - return; - } catch { /* ignored */ } - } - - const fallback = readFallback(); - fallback[sessionId] = { messageId, completedAt }; - writeFallback(fallback); -}; - -export const readSessionCursor = async ( - sessionId: string -): Promise => { - if (!sessionId) { - return null; - } - - if (hasIndexedDbSupport()) { - try { - const db = await getDatabase(); - const record = await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readonly'); - const store = tx.objectStore(STORE_NAME); - const request = store.get(sessionId); - - request.onsuccess = () => { - resolve((request.result as CursorRecord) ?? null); - }; - - request.onerror = () => reject(request.error ?? new Error('Cursor read failed')); - tx.onerror = () => reject(tx.error ?? new Error('Cursor read failed')); - tx.onabort = () => reject(tx.error ?? new Error('Cursor read aborted')); - }); - return record; - } catch { /* ignored */ } - } - - const fallback = readFallback(); - return fallback[sessionId] ?? null; -}; - -export const clearSessionCursor = async (sessionId: string) => { - if (!sessionId) { - return; - } - - if (hasIndexedDbSupport()) { - try { - const db = await getDatabase(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - store.delete(sessionId); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error ?? new Error('Cursor delete failed')); - tx.onabort = () => reject(tx.error ?? new Error('Cursor delete aborted')); - }); - } catch { /* ignored */ } - } - - const fallback = readFallback(); - if (sessionId in fallback) { - delete fallback[sessionId]; - writeFallback(fallback); - } -}; diff --git a/packages/ui/src/lib/messages/agentMentions.ts b/packages/ui/src/lib/messages/agentMentions.ts index 42ba488a..6feb6afb 100644 --- a/packages/ui/src/lib/messages/agentMentions.ts +++ b/packages/ui/src/lib/messages/agentMentions.ts @@ -1,12 +1,12 @@ import type { Agent } from "@opencode-ai/sdk/v2"; -export interface AgentMentionSource { +interface AgentMentionSource { value: string; start: number; end: number; } -export interface ParsedAgentMention { +interface ParsedAgentMention { name: string; source?: AgentMentionSource; } diff --git a/packages/ui/src/lib/messages/executionMeta.ts b/packages/ui/src/lib/messages/executionMeta.ts index 75af32ce..017528f5 100644 --- a/packages/ui/src/lib/messages/executionMeta.ts +++ b/packages/ui/src/lib/messages/executionMeta.ts @@ -1,9 +1,3 @@ -export const EXECUTION_FORK_META_TEXT = - "This message comes from an AI assistant in another session. The user wants you to respond according to its content: " + - "if it is an implementation plan, your task is to implement that plan; " + - "if it is a conclusion or summary, your task is to verify it, explain whether you agree or disagree, and correct it if needed. " + - "Always clearly state what you understand your task to be, and wait for the user's approval of your conclusions before taking any further actions."; - export const MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT = "This message bellow comes from an AI agent in another session. I want you to act according to its content: " + "if it is an implementation plan, your task is to implement that plan; " + @@ -12,9 +6,6 @@ export const MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT = "Proceed with actions right away based on your understanding of the task. " + "Here is the content of the message: "; -export const isExecutionForkMetaText = (text: string | null | undefined): boolean => - typeof text === 'string' && text.trim() === EXECUTION_FORK_META_TEXT.trim(); - // Default, user-editable instructions prefilled in the "Start new session from // this answer" dialog. Mirrors the previous fixed fork instruction so existing // behavior is preserved unless the user edits it. @@ -26,7 +17,7 @@ export const EXECUTION_FORK_DEFAULT_INSTRUCTIONS = // Fixed connective that opens the forked assistant content. Not editable by the // user — it sits between the user's instructions and the assistant message. -export const EXECUTION_FORK_CONTENT_PREFACE = +const EXECUTION_FORK_CONTENT_PREFACE = "This message below comes from an AI agent in another session. Here is the content of the message:"; // Builds the final message sent to the new session: diff --git a/packages/ui/src/lib/messages/inlineComments.ts b/packages/ui/src/lib/messages/inlineComments.ts index 358ef0f2..8cb42dea 100644 --- a/packages/ui/src/lib/messages/inlineComments.ts +++ b/packages/ui/src/lib/messages/inlineComments.ts @@ -4,7 +4,7 @@ import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; * Format a single inline comment draft into the standard message format * used by diff, plan, and file viewers */ -export function formatInlineCommentDraft(draft: InlineCommentDraft): string { +function formatInlineCommentDraft(draft: InlineCommentDraft): string { const { fileLabel, startLine, endLine, side, language, code, text } = draft; // Diff format includes side (original/modified) @@ -28,7 +28,7 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string { * Format multiple inline comment drafts into a single string * with each comment separated by a blank line */ -export function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string { +function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string { if (drafts.length === 0) return ''; if (drafts.every((draft) => draft.source === 'preview-annotation')) { @@ -55,16 +55,3 @@ export function appendInlineComments(text: string, drafts: InlineCommentDraft[]) return `${text}\n\n${formattedComments}`; } -/** - * Check if a message text contains inline comments (for validation purposes) - */ -export function hasInlineComments(text: string): boolean { - return text.includes('Comment on `') && text.includes('```'); -} - -/** - * Extract the file label from a draft for display purposes - */ -export function getDraftDisplayLabel(draft: InlineCommentDraft): string { - return `${draft.fileLabel}:${draft.startLine}-${draft.endLine}`; -} diff --git a/packages/ui/src/lib/messages/inlineMessageLinks.ts b/packages/ui/src/lib/messages/inlineMessageLinks.ts index c656aa33..e3aa1206 100644 --- a/packages/ui/src/lib/messages/inlineMessageLinks.ts +++ b/packages/ui/src/lib/messages/inlineMessageLinks.ts @@ -1,5 +1,5 @@ -export const SKILL_LINK_PREFIX = '#openchamber-skill:'; -export const AGENT_LINK_PREFIX = '#openchamber-agent:'; +const SKILL_LINK_PREFIX = '#openchamber-skill:'; +const AGENT_LINK_PREFIX = '#openchamber-agent:'; export const buildAgentMentionUrl = (name: string): string => { const encoded = encodeURIComponent(name); diff --git a/packages/ui/src/lib/mobileKeyboardMode.ts b/packages/ui/src/lib/mobileKeyboardMode.ts index 07283ca2..1e2eccf5 100644 --- a/packages/ui/src/lib/mobileKeyboardMode.ts +++ b/packages/ui/src/lib/mobileKeyboardMode.ts @@ -1,8 +1,8 @@ export type MobileKeyboardMode = 'native' | 'resize-content'; -export const MOBILE_KEYBOARD_MODE_STORAGE_KEY = 'openchamber.mobileKeyboardMode'; -export const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]'; -export const VIEWPORT_CONTENT_BASE = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; +const MOBILE_KEYBOARD_MODE_STORAGE_KEY = 'openchamber.mobileKeyboardMode'; +const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]'; +const VIEWPORT_CONTENT_BASE = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; export const supportsMobileKeyboardResizeContent = (): boolean => { return true; @@ -21,7 +21,7 @@ export function normalizeMobileKeyboardMode( return fallback; } -export const getViewportContentForMobileKeyboardMode = (value: unknown): string => { +const getViewportContentForMobileKeyboardMode = (value: unknown): string => { const mode = normalizeMobileKeyboardMode(value); return mode === 'resize-content' ? `${VIEWPORT_CONTENT_BASE}, interactive-widget=resizes-content` diff --git a/packages/ui/src/lib/mobileLayoutPreference.ts b/packages/ui/src/lib/mobileLayoutPreference.ts index befdb0de..10050bf2 100644 --- a/packages/ui/src/lib/mobileLayoutPreference.ts +++ b/packages/ui/src/lib/mobileLayoutPreference.ts @@ -2,7 +2,7 @@ export type MobileLayoutPreference = 'default' | 'new'; const MOBILE_LAYOUT_PREFERENCE_KEY = 'openchamber-mobile-layout'; -export const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => { +const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => { // 'new' is the default; only an explicit 'default' (the legacy/"Old" layout) // opts out of it. return value === 'default' ? 'default' : 'new'; diff --git a/packages/ui/src/lib/multirun/title.ts b/packages/ui/src/lib/multirun/title.ts index 44a82a11..fb2cabcb 100644 --- a/packages/ui/src/lib/multirun/title.ts +++ b/packages/ui/src/lib/multirun/title.ts @@ -77,10 +77,6 @@ export const getMultiRunSessionTitle = (parts: { return segments.join('/'); }; -export const isMultiRunSessionTitle = (title?: string | null): boolean => { - return parseMultiRunSessionTitle(title) !== null; -}; - export const getFusionSessionTitle = (groupSlug: string, providerID: string, modelID: string, runGroup?: string): string => { const segments = [groupSlug]; if (runGroup) segments.push(runGroup); diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index a6b1f429..31c7578b 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -148,7 +148,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st return 'direct executable'; }; -export const buildOpenCodeStatusReport = async (): Promise => { +const buildOpenCodeStatusReport = async (): Promise => { const now = new Date(); const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)'; const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)'; diff --git a/packages/ui/src/lib/openInApps.ts b/packages/ui/src/lib/openInApps.ts index 040f4feb..96d85399 100644 --- a/packages/ui/src/lib/openInApps.ts +++ b/packages/ui/src/lib/openInApps.ts @@ -32,7 +32,6 @@ export const OPEN_IN_APPS: OpenInApp[] = [ export const DEFAULT_OPEN_IN_APP_ID = 'finder'; export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); -export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']); export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => { if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') { @@ -50,7 +49,3 @@ export const getOpenInAppById = (id: string | null | undefined): OpenInApp | nul const app = OPEN_IN_APPS.find((candidate) => candidate.id === id) ?? null; return app ? getPlatformOpenInApp(app) : null; }; - -export const getDefaultOpenInApp = (): OpenInApp => { - return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? getPlatformOpenInApp(OPEN_IN_APPS[0]); -}; diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index e80fa289..f17a4b20 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -30,7 +30,7 @@ function getRuntimeFilesAPI(): FilesAPI | null { return null; } -export interface OpenChamberConfig { +interface OpenChamberConfig { projectPath?: string; 'setup-worktree'?: string[]; 'setup-worktree-wait'?: boolean; @@ -42,7 +42,7 @@ export interface OpenChamberConfig { draftStarters?: DraftStarterRef[]; } -export type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows'; +type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows'; export interface OpenChamberProjectAction { id: string; @@ -91,11 +91,11 @@ export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTo export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 3000; export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120; -export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80; -export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000; -export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000; -export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; -export const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160; +const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80; +const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000; +const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000; +const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; +const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160; const OPENCHAMBER_ACTION_PLATFORM_SET = new Set(['macos', 'linux', 'windows']); @@ -520,7 +520,7 @@ const getProjectPlansDirectory = async (project: ProjectRef): Promise { +const formatProjectPlanMarkdown = (title: string, body: string): string => { const normalizedTitle = sanitizePlanTitle(title) || 'Plan'; const normalizedBody = body.trim(); return normalizedBody @@ -552,7 +552,7 @@ export const parseProjectPlanMarkdown = (raw: string): { title: string; body: st * Read the config for a project. * Returns null if file doesn't exist or is invalid. */ -export async function readOpenChamberConfig(project: ProjectRef): Promise { +async function readOpenChamberConfig(project: ProjectRef): Promise { const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; if (!projectDirectory) { return null; @@ -624,7 +624,7 @@ export async function readOpenChamberConfig(project: ProjectRef): Promise { @@ -678,7 +678,7 @@ export async function writeOpenChamberConfig( /** * Update specific keys in the config, preserving other values. */ -export async function updateOpenChamberConfig( +async function updateOpenChamberConfig( project: ProjectRef, updates: Partial ): Promise { @@ -753,12 +753,12 @@ export async function getProjectContextData(project: ProjectRef): Promise { +async function getProjectPlanFiles(project: ProjectRef): Promise { const config = await readOpenChamberConfig(project); return sanitizeProjectPlanFileLinks(config?.projectPlanFiles); } -export async function saveProjectPlanFiles( +async function saveProjectPlanFiles( project: ProjectRef, value: OpenChamberProjectPlanFileLink[] ): Promise { diff --git a/packages/ui/src/lib/openchamberEvents.ts b/packages/ui/src/lib/openchamberEvents.ts index bf481885..0c624c2b 100644 --- a/packages/ui/src/lib/openchamberEvents.ts +++ b/packages/ui/src/lib/openchamberEvents.ts @@ -1,7 +1,7 @@ import { getRuntimeUrlResolver } from './runtime-url'; import { subscribeRuntimeEndpointChanged } from './runtime-switch'; -export type ScheduledTaskRanEvent = { +type ScheduledTaskRanEvent = { type: 'scheduled-task-ran'; projectId: string; taskId: string; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 0a6163c8..aac2bd42 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -7,7 +7,6 @@ import type { Part, Provider, Config, - Model, Agent, TextPartInput, FilePartInput, @@ -170,7 +169,7 @@ interface App { [key: string]: unknown; } -export type FilesystemEntry = { +type FilesystemEntry = { name: string; path: string; isDirectory: boolean; @@ -203,7 +202,7 @@ type FileInputLite = { url: string; }; -export type DirectorySwitchResult = { +type DirectorySwitchResult = { success: boolean; restarted: boolean; path: string; @@ -1830,5 +1829,3 @@ class OpencodeService { export const opencodeClient = new OpencodeService(); // Exported types -export type { Session, Message, Part, Provider, Config, Model }; -export type { App }; diff --git a/packages/ui/src/lib/opencode/provider-tracker.ts b/packages/ui/src/lib/opencode/provider-tracker.ts index 4a792bf8..5f613fb5 100644 --- a/packages/ui/src/lib/opencode/provider-tracker.ts +++ b/packages/ui/src/lib/opencode/provider-tracker.ts @@ -90,7 +90,7 @@ function isCircuitBreakerStatus(status?: number): boolean { return status !== undefined && RETRYABLE_STATUS_CODES.has(status) } -export function isCircuitOpen(providerID: string): boolean { +function isCircuitOpen(providerID: string): boolean { const state = providers.get(providerID) if (!state?.circuitOpen) return false @@ -124,11 +124,3 @@ export function getRetryDelayMs(attempt: number): number { const delay = DEFAULT_RETRY_BASE_DELAY_MS * 2 ** attempt return Math.min(delay, DEFAULT_RETRY_MAX_DELAY_MS) } - -export function resetCircuit(providerID: string): void { - const state = providers.get(providerID) - if (!state) return - state.consecutiveErrors = 0 - state.circuitOpen = false - state.circuitCooldownMs = DEFAULT_CIRCUIT_COOLDOWN_MS -} diff --git a/packages/ui/src/lib/outsideFileGrants.ts b/packages/ui/src/lib/outsideFileGrants.ts index 41e51b2f..95e6f509 100644 --- a/packages/ui/src/lib/outsideFileGrants.ts +++ b/packages/ui/src/lib/outsideFileGrants.ts @@ -28,7 +28,7 @@ export const getOutsideFileGrant = (path: string): string | undefined => { return entry.outsideFileGrant; }; -export const rememberOutsideFileGrant = ( +const rememberOutsideFileGrant = ( path: string, outsideFileGrant: string, expiresAt?: number, diff --git a/packages/ui/src/lib/passkeys.ts b/packages/ui/src/lib/passkeys.ts index 820eb449..ed01cfb4 100644 --- a/packages/ui/src/lib/passkeys.ts +++ b/packages/ui/src/lib/passkeys.ts @@ -54,7 +54,7 @@ const postJson = async (url: string, body?: unknown): Promise => runti body: body === undefined ? undefined : JSON.stringify(body), }); -export const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise => { +const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise => { try { const payload = await response.json(); if (payload && typeof payload.error === 'string' && payload.error.trim()) { diff --git a/packages/ui/src/lib/path-utils.ts b/packages/ui/src/lib/path-utils.ts index eb205760..0b6d88b8 100644 --- a/packages/ui/src/lib/path-utils.ts +++ b/packages/ui/src/lib/path-utils.ts @@ -33,7 +33,7 @@ export const isAbsoluteFilePath = (value: string | null | undefined): boolean => return normalized.startsWith('/') || WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized); }; -export const toComparableFilePath = (value: string | null | undefined): string => { +const toComparableFilePath = (value: string | null | undefined): string => { const normalized = normalizeFilePath(value); return WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized) || normalized.startsWith('//') ? normalized.toLowerCase() diff --git a/packages/ui/src/lib/permissions/editPermissionDefaults.ts b/packages/ui/src/lib/permissions/editPermissionDefaults.ts deleted file mode 100644 index 08ef192e..00000000 --- a/packages/ui/src/lib/permissions/editPermissionDefaults.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { EditPermissionMode } from '@/stores/types/sessionTypes'; - -export type BashPermissionValue = 'allow' | 'ask' | 'deny'; -export type BashPermissionSetting = BashPermissionValue | Record; -export type SimplePermissionValue = 'allow' | 'ask' | 'deny' | undefined; - -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null && !Array.isArray(value); -}; - -const isBashPermissionMap = (value: unknown): value is Record => { - return isRecord(value); -}; - -const hasBashAskEntry = (permission?: BashPermissionSetting): boolean => { - if (!permission) { - return false; - } - if (typeof permission === 'string') { - return permission === 'ask'; - } - if (isBashPermissionMap(permission)) { - return Object.values(permission).some((value) => value === 'ask'); - } - return false; -}; - -const hasBashDenyEntry = (permission?: BashPermissionSetting): boolean => { - if (!permission) { - return false; - } - if (typeof permission === 'string') { - return permission === 'deny'; - } - if (isBashPermissionMap(permission)) { - return Object.values(permission).some((value) => value === 'deny'); - } - return false; -}; - -export interface EditPermissionInputs { - agentDefaultEditMode: EditPermissionMode; - webfetchPermission?: SimplePermissionValue; - bashPermission?: BashPermissionSetting; -} - -export interface EditPermissionUIState { - cascadeDefaultMode: EditPermissionMode; - modeAvailability: Record; - autoApproveAvailable: boolean; - bashHasAsk: boolean; - bashHasDeny: boolean; - bashAllAllow: boolean; - webfetchIsAllow: boolean; - webfetchNotDeny: boolean; -} - -export const calculateEditPermissionUIState = ({ - agentDefaultEditMode, - webfetchPermission, - bashPermission, -}: EditPermissionInputs): EditPermissionUIState => { - const bashHasAsk = hasBashAskEntry(bashPermission); - const bashHasDeny = hasBashDenyEntry(bashPermission); - const bashAllAllow = !bashHasAsk && !bashHasDeny; - - const webfetchIsAllow = webfetchPermission === 'allow'; - const webfetchNotDeny = webfetchPermission !== 'deny'; - - const editIsAllow = agentDefaultEditMode === 'allow' || agentDefaultEditMode === 'full'; - const editIsAsk = agentDefaultEditMode === 'ask'; - - let cascadeDefaultMode: EditPermissionMode = agentDefaultEditMode; - if (editIsAllow && webfetchIsAllow && bashAllAllow) { - cascadeDefaultMode = 'full'; - } else if (editIsAllow && bashHasAsk) { - cascadeDefaultMode = 'allow'; - } else if (editIsAsk) { - cascadeDefaultMode = 'ask'; - } - - const modeAvailability: Record = { - ask: editIsAsk, - allow: editIsAsk || (editIsAllow && bashHasAsk), - full: agentDefaultEditMode !== 'deny' && webfetchNotDeny && bashHasAsk, - deny: false, - }; - - const autoApproveAvailable = modeAvailability.allow || modeAvailability.full; - - return { - cascadeDefaultMode, - modeAvailability, - autoApproveAvailable, - bashHasAsk, - bashHasDeny, - bashAllAllow, - webfetchIsAllow, - webfetchNotDeny, - }; -}; diff --git a/packages/ui/src/lib/projectActions.ts b/packages/ui/src/lib/projectActions.ts index a6ee9726..74644d29 100644 --- a/packages/ui/src/lib/projectActions.ts +++ b/packages/ui/src/lib/projectActions.ts @@ -1,7 +1,3 @@ -import type { - OpenChamberProjectAction, - OpenChamberProjectActionPlatform, -} from '@/lib/openchamberConfig'; import type { DesktopSshInstance, DesktopSshPortForward, @@ -70,30 +66,6 @@ export const normalizeProjectActionDirectory = (value: string): string => { return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed; }; -export const getCurrentProjectActionPlatform = (): OpenChamberProjectActionPlatform => { - if (typeof navigator === 'undefined') { - return 'macos'; - } - const ua = (navigator.userAgent || '').toLowerCase(); - if (ua.includes('windows')) { - return 'windows'; - } - if (ua.includes('linux')) { - return 'linux'; - } - return 'macos'; -}; - -export const isProjectActionEnabledOnPlatform = ( - action: OpenChamberProjectAction, - platform: OpenChamberProjectActionPlatform -): boolean => { - if (!Array.isArray(action.platforms) || action.platforms.length === 0) { - return true; - } - return action.platforms.includes(platform); -}; - export const toProjectActionRunKey = (directory: string, actionId: string): string => { return `${normalizeProjectActionDirectory(directory)}::${actionId}`; }; diff --git a/packages/ui/src/lib/projectMeta.ts b/packages/ui/src/lib/projectMeta.ts index a5f14b55..1e918542 100644 --- a/packages/ui/src/lib/projectMeta.ts +++ b/packages/ui/src/lib/projectMeta.ts @@ -5,7 +5,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import type { IconName } from "@/components/icon/icons"; type ThemeVariant = 'light' | 'dark'; -export type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string }; +type ProjectIconImageOptions = { themeVariant?: ThemeVariant; iconColor?: string }; const PROJECT_ICON_OBJECT_URL_CACHE_LIMIT = 200; @@ -150,7 +150,7 @@ const loadProjectIconObjectUrl = ( return promise; }; -export const useProjectIconImageObjectUrl = ( +const useProjectIconImageObjectUrl = ( project: Pick, options?: ProjectIconImageOptions, ): string | null => { diff --git a/packages/ui/src/lib/projectResolution.ts b/packages/ui/src/lib/projectResolution.ts index 21c48255..834231e9 100644 --- a/packages/ui/src/lib/projectResolution.ts +++ b/packages/ui/src/lib/projectResolution.ts @@ -26,7 +26,7 @@ export const resolveProjectForDirectory = ( return best; }; -export const resolveProjectFromWorktreeDirectory = ( +const resolveProjectFromWorktreeDirectory = ( projects: ProjectEntry[], availableWorktreesByProject: Map, directory: string | null, diff --git a/packages/ui/src/lib/pwa.ts b/packages/ui/src/lib/pwa.ts index 4be9c352..a7428c42 100644 --- a/packages/ui/src/lib/pwa.ts +++ b/packages/ui/src/lib/pwa.ts @@ -8,7 +8,6 @@ export type PWADisplayMode = const DISPLAY_MODES: Array> = ['standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay']; -export const PWA_INSTALL_NAME_STORAGE_KEY = 'openchamber.pwaName'; export const PWA_RECENT_SESSIONS_STORAGE_KEY = 'openchamber.pwaRecentSessions'; const matchesDisplayMode = (mode: Exclude): boolean => { @@ -35,7 +34,3 @@ export const getPWADisplayMode = (): PWADisplayMode => { const matched = DISPLAY_MODES.find((mode) => matchesDisplayMode(mode)); return matched ?? 'browser'; }; - -export const isInstalledPWARuntime = (): boolean => { - return getPWADisplayMode() !== 'browser'; -}; diff --git a/packages/ui/src/lib/quota/index.ts b/packages/ui/src/lib/quota/index.ts index a2a4cc7c..51b3c4e1 100644 --- a/packages/ui/src/lib/quota/index.ts +++ b/packages/ui/src/lib/quota/index.ts @@ -1,16 +1,13 @@ -export { QUOTA_PROVIDERS, QUOTA_PROVIDER_MAP } from './providers'; -export type { QuotaProviderMeta } from './providers'; +export { QUOTA_PROVIDERS } from './providers'; export { clampPercent, - formatPercent, formatQuotaValueLabel, formatQuotaResetLabel, resolveUsageTone, formatWindowLabel, calculatePace, - inferWindowSeconds, getPaceStatusColor, formatRemainingTime, calculateExpectedUsagePercent, } from './utils'; -export type { PaceStatus, PaceInfo } from './utils'; +export type { PaceInfo } from './utils'; diff --git a/packages/ui/src/lib/quota/model-families.ts b/packages/ui/src/lib/quota/model-families.ts index ba68b205..69d7eee0 100644 --- a/packages/ui/src/lib/quota/model-families.ts +++ b/packages/ui/src/lib/quota/model-families.ts @@ -25,21 +25,6 @@ export function getDisplayModelName(modelName: string): string { return modelName; } -/** - * Get the auth source label from a model name prefix. - * e.g., "gemini/..." -> "Gemini" - * "antigravity/..." -> "Antigravity" - */ -export function getAuthSourceLabel(modelName: string): string | null { - const slashIndex = modelName.indexOf('/'); - if (slashIndex === -1) return null; - - const prefix = modelName.substring(0, slashIndex); - if (prefix === 'gemini') return 'Gemini'; - if (prefix === 'antigravity') return 'Antigravity'; - return null; -} - const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [ { id: 'gemini-auth', @@ -55,11 +40,11 @@ const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [ }, ]; -export const PROVIDER_MODEL_FAMILIES: Record = { +const PROVIDER_MODEL_FAMILIES: Record = { google: GOOGLE_MODEL_FAMILIES, }; -export function getModelFamily(modelName: string, providerId: QuotaProviderId): ModelFamily | null { +function getModelFamily(modelName: string, providerId: QuotaProviderId): ModelFamily | null { const families = PROVIDER_MODEL_FAMILIES[providerId] ?? []; for (const family of families) { if (family.matcher(modelName)) { diff --git a/packages/ui/src/lib/quota/providers/base.ts b/packages/ui/src/lib/quota/providers/base.ts deleted file mode 100644 index f7af0ba0..00000000 --- a/packages/ui/src/lib/quota/providers/base.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ProviderResult, QuotaProviderId } from '@/types'; - -export interface QuotaProvider { - id: QuotaProviderId; - name: string; - isConfigured: () => Promise; - fetchQuota: () => Promise; -} diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 30f2b4aa..4b5c0a46 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -21,10 +21,3 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'ollama-cloud', name: 'Ollama Cloud' }, { id: 'wafer', name: 'Wafer.ai' }, ]; - -export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce< - Record ->((acc, provider) => { - acc[provider.id] = provider; - return acc; -}, {}); diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 85432784..912c917e 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -132,7 +132,7 @@ export interface PaceInfo { * Infer window duration in seconds from a window label. * Used when the API doesn't provide windowSeconds directly. */ -export const inferWindowSeconds = (label: string): number | null => { +const inferWindowSeconds = (label: string): number | null => { const normalized = label.toLowerCase().trim(); // Exact matches diff --git a/packages/ui/src/lib/responseStyle.ts b/packages/ui/src/lib/responseStyle.ts index d01825d8..1aae6b31b 100644 --- a/packages/ui/src/lib/responseStyle.ts +++ b/packages/ui/src/lib/responseStyle.ts @@ -26,7 +26,7 @@ export const getResponseStylePresetInstructions = (preset: ResponseStylePreset): } }; -export const buildResponseStyleInstruction = ({ +const buildResponseStyleInstruction = ({ enabled, preset, customInstructions, diff --git a/packages/ui/src/lib/reviewFlow.ts b/packages/ui/src/lib/reviewFlow.ts index c1180da1..353d1271 100644 --- a/packages/ui/src/lib/reviewFlow.ts +++ b/packages/ui/src/lib/reviewFlow.ts @@ -5,7 +5,6 @@ import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { getOriginalSessionID, getReviewSessionID, - getSessionMetadata, isReviewSession, withoutReviewSessionLink, withReviewSessionLink, @@ -289,5 +288,3 @@ export const getReviewTransferDirection = (session: Session | null | undefined): if (getReviewSessionID(session)) return 'original-to-review'; return null; }; - -export const readSessionReviewMetadata = (session: Session | null | undefined) => getSessionMetadata(session); diff --git a/packages/ui/src/lib/router/index.ts b/packages/ui/src/lib/router/index.ts index 896636c2..c6f2f23c 100644 --- a/packages/ui/src/lib/router/index.ts +++ b/packages/ui/src/lib/router/index.ts @@ -17,15 +17,12 @@ * - `/?tab=diff&file=src/main.ts` - Open diff view with file */ -export type { RouteState, RouterContext } from './types'; -export { VALID_TABS, VALID_SETTINGS_SECTIONS, ROUTE_PARAMS } from './types'; +export type { RouteState } from './types'; + export { parseRoute, hasRouteParams } from './parseRoute'; export type { AppRouteState } from './serializeRoute'; export { - serializeRoute, - buildURL, - routeMatchesURL, updateBrowserURL, } from './serializeRoute'; diff --git a/packages/ui/src/lib/router/serializeRoute.ts b/packages/ui/src/lib/router/serializeRoute.ts index 7218a637..6c9a9c50 100644 --- a/packages/ui/src/lib/router/serializeRoute.ts +++ b/packages/ui/src/lib/router/serializeRoute.ts @@ -21,7 +21,7 @@ const DEFAULT_TAB: MainTab = 'chat'; * Serialize application state to URL search parameters. * Only includes parameters that differ from defaults to keep URLs clean. */ -export function serializeRoute(state: AppRouteState): URLSearchParams { +function serializeRoute(state: AppRouteState): URLSearchParams { const params = new URLSearchParams(); // Session ID - always include if present @@ -54,7 +54,7 @@ export function serializeRoute(state: AppRouteState): URLSearchParams { * Convert URLSearchParams to a URL string. * Returns just the pathname if no params, otherwise pathname + search string. */ -export function buildURL(params: URLSearchParams, pathname?: string): string { +function buildURL(params: URLSearchParams, pathname?: string): string { const path = pathname ?? (typeof window !== 'undefined' ? window.location.pathname : '/'); const search = params.toString(); @@ -69,7 +69,7 @@ export function buildURL(params: URLSearchParams, pathname?: string): string { * Check if the current URL matches the given route state. * Used to avoid unnecessary URL updates. */ -export function routeMatchesURL(state: AppRouteState): boolean { +function routeMatchesURL(state: AppRouteState): boolean { if (typeof window === 'undefined') { return true; } diff --git a/packages/ui/src/lib/router/types.ts b/packages/ui/src/lib/router/types.ts index 0341587f..3978b450 100644 --- a/packages/ui/src/lib/router/types.ts +++ b/packages/ui/src/lib/router/types.ts @@ -16,16 +16,6 @@ export interface RouteState { diffFile: string | null; } -/** - * Context for router operations - determines what capabilities are available. - */ -export interface RouterContext { - /** Whether running in VS Code webview (limited URL capabilities) */ - isVSCode: boolean; - /** Whether URL can be updated (false in VS Code, true elsewhere) */ - canUpdateURL: boolean; -} - /** * Valid main tab values for URL routing. */ diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index b8155fe4..811a8232 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -1,4 +1,4 @@ -export type RuntimeAuthCredential = +type RuntimeAuthCredential = | { type: 'bearer'; token: string } | null; @@ -109,7 +109,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => { return token; }; -export const getRuntimeAuthCredential = async (): Promise => { +const getRuntimeAuthCredential = async (): Promise => { const credential = await credentialProvider(); const token = credential?.type === 'bearer' ? normalizeBearerToken(credential.token) diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index d3449113..d1bc46ab 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -150,5 +150,3 @@ export const configureRuntimeUrlResolver = (config: RuntimeUrlConfig): RuntimeUr activeRuntimeUrlResolver = createRuntimeUrlResolver(config); return activeRuntimeUrlResolver; }; - -export const runtimeUrl = activeRuntimeUrlResolver; diff --git a/packages/ui/src/lib/runtimeSurface.ts b/packages/ui/src/lib/runtimeSurface.ts index f820c537..d23b4af7 100644 --- a/packages/ui/src/lib/runtimeSurface.ts +++ b/packages/ui/src/lib/runtimeSurface.ts @@ -20,7 +20,7 @@ const isTouchOrCoarsePointer = (): boolean => { return coarsePointer || touchPoints > 0; }; -export const detectHostedSurface = (): HostedSurface => { +const detectHostedSurface = (): HostedSurface => { if (typeof window === 'undefined') return 'desktop'; const explicitSurface = window.__OPENCHAMBER_SURFACE__; diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts index d9713be8..99cd7446 100644 --- a/packages/ui/src/lib/search/fuzzySearch.ts +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -89,24 +89,6 @@ function getFuzzyMatchMask( 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; -} - /** * Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring * matches (especially prefix matches), and returns the top N. @@ -159,15 +141,6 @@ export function scoreByFuzzyQuery( return scored.slice(0, limit); } -export function rankByFuzzyQuery( - items: T[], - query: string, - getText: (item: T) => string, - options?: { limit?: number; threshold?: number; noFuzzy?: boolean }, -): T[] { - return scoreByFuzzyQuery(items, query, getText, options).map((x) => x.item); -} - export function partitionByFuzzyQuery( items: T[], query: string, diff --git a/packages/ui/src/lib/server-compatibility.test.ts b/packages/ui/src/lib/server-compatibility.test.ts deleted file mode 100644 index 2cd85e09..00000000 --- a/packages/ui/src/lib/server-compatibility.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { evaluateServerCompatibility, REQUIRED_SERVER_CAPABILITIES } from './server-compatibility'; - -const compatiblePayload = () => ({ - status: 'ok', - openchamberVersion: '1.10.4', - runtime: 'web', - compatibility: { - apiVersion: 1, - minClientApiVersion: 1, - capabilities: [...REQUIRED_SERVER_CAPABILITIES], - }, -}); - -describe('evaluateServerCompatibility', () => { - test('accepts a compatible server', () => { - const result = evaluateServerCompatibility(compatiblePayload()); - - expect(result.status).toBe('compatible'); - expect(result.openchamberVersion).toBe('1.10.4'); - expect(result.runtime).toBe('web'); - }); - - test('rejects invalid compatibility payloads', () => { - expect(evaluateServerCompatibility({ status: 'ok' }).status).toBe('invalid-response'); - expect(evaluateServerCompatibility(null).status).toBe('invalid-response'); - }); - - test('detects old servers and old clients', () => { - expect(evaluateServerCompatibility({ - ...compatiblePayload(), - compatibility: { ...compatiblePayload().compatibility, apiVersion: 1 }, - }, { clientApiVersion: 2 }).status).toBe('server-too-old'); - - expect(evaluateServerCompatibility({ - ...compatiblePayload(), - compatibility: { ...compatiblePayload().compatibility, minClientApiVersion: 2 }, - }, { clientApiVersion: 1 }).status).toBe('client-too-old'); - }); - - test('detects missing required capabilities', () => { - const result = evaluateServerCompatibility({ - ...compatiblePayload(), - compatibility: { - ...compatiblePayload().compatibility, - capabilities: ['api.health.v1'], - }, - }); - - expect(result.status).toBe('missing-capability'); - expect(result.missingCapabilities).toContain('realtime.sse.v1'); - }); -}); diff --git a/packages/ui/src/lib/server-compatibility.ts b/packages/ui/src/lib/server-compatibility.ts deleted file mode 100644 index 86d33a7e..00000000 --- a/packages/ui/src/lib/server-compatibility.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { runtimeFetch } from './runtime-fetch'; - -export const OPENCHAMBER_CLIENT_API_VERSION = 1; - -export const REQUIRED_SERVER_CAPABILITIES = [ - 'api.health.v1', - 'api.runtime-url.v1', - 'api.raw-file.v1', - 'realtime.sse.v1', - 'realtime.websocket.global-events.v1', -] as const; - -export type ServerCompatibilityStatus = - | 'compatible' - | 'auth-required' - | 'unreachable' - | 'invalid-response' - | 'server-too-old' - | 'client-too-old' - | 'missing-capability'; - -export interface ServerCompatibilityPayload { - status?: unknown; - openchamberVersion?: unknown; - runtime?: unknown; - compatibility?: { - apiVersion?: unknown; - minClientApiVersion?: unknown; - capabilities?: unknown; - } | null; -} - -export interface ServerCompatibilityResult { - status: ServerCompatibilityStatus; - openchamberVersion: string | null; - runtime: string | null; - apiVersion: number | null; - minClientApiVersion: number | null; - missingCapabilities: string[]; - requiredCapabilities: string[]; - message: string; -} - -const parsePositiveInteger = (value: unknown): number | null => { - if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null; - return value; -}; - -const parseString = (value: unknown): string | null => { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; -}; - -const parseCapabilities = (value: unknown): Set => { - if (!Array.isArray(value)) return new Set(); - return new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)); -}; - -export const evaluateServerCompatibility = ( - payload: ServerCompatibilityPayload | null | undefined, - options: { - clientApiVersion?: number; - requiredCapabilities?: readonly string[]; - } = {}, -): ServerCompatibilityResult => { - const clientApiVersion = options.clientApiVersion ?? OPENCHAMBER_CLIENT_API_VERSION; - const requiredCapabilities = [...(options.requiredCapabilities ?? REQUIRED_SERVER_CAPABILITIES)]; - const compatibility = payload?.compatibility ?? null; - const apiVersion = parsePositiveInteger(compatibility?.apiVersion); - const minClientApiVersion = parsePositiveInteger(compatibility?.minClientApiVersion); - const openchamberVersion = parseString(payload?.openchamberVersion); - const runtime = parseString(payload?.runtime); - - const base = { - openchamberVersion, - runtime, - apiVersion, - minClientApiVersion, - missingCapabilities: [] as string[], - requiredCapabilities, - }; - - if (!payload || payload.status !== 'ok' || !compatibility || !apiVersion || !minClientApiVersion) { - return { - ...base, - status: 'invalid-response', - message: 'Server did not return OpenChamber compatibility metadata.', - }; - } - - if (apiVersion < clientApiVersion) { - return { - ...base, - status: 'server-too-old', - message: `Server API version ${apiVersion} is older than required client API version ${clientApiVersion}.`, - }; - } - - if (minClientApiVersion > clientApiVersion) { - return { - ...base, - status: 'client-too-old', - message: `Server requires client API version ${minClientApiVersion}, but this client supports ${clientApiVersion}.`, - }; - } - - const capabilities = parseCapabilities(compatibility.capabilities); - const missingCapabilities = requiredCapabilities.filter((capability) => !capabilities.has(capability)); - if (missingCapabilities.length > 0) { - return { - ...base, - status: 'missing-capability', - missingCapabilities, - message: `Server is missing required capabilities: ${missingCapabilities.join(', ')}.`, - }; - } - - return { - ...base, - status: 'compatible', - message: 'Server is compatible.', - }; -}; - -export const checkServerCompatibility = async (): Promise => { - let response: Response; - try { - response = await runtimeFetch('/api/version', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - } catch (error) { - return { - status: 'unreachable', - openchamberVersion: null, - runtime: null, - apiVersion: null, - minClientApiVersion: null, - missingCapabilities: [], - requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES], - message: error instanceof Error ? error.message : 'Server is unreachable.', - }; - } - - if (response.status === 401 || response.status === 403) { - return { - status: 'auth-required', - openchamberVersion: null, - runtime: null, - apiVersion: null, - minClientApiVersion: null, - missingCapabilities: [], - requiredCapabilities: [...REQUIRED_SERVER_CAPABILITIES], - message: 'Server requires authentication.', - }; - } - - const payload = await response.json().catch(() => null) as ServerCompatibilityPayload | null; - return evaluateServerCompatibility(payload); -}; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index b0331f05..40dc7e0d 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -25,7 +25,7 @@ export type SettingsPageSlug = | 'tunnel' | 'about'; -export type SettingsPageGroup = +type SettingsPageGroup = | 'appearance' | 'projects' | 'general' @@ -52,17 +52,6 @@ export interface SettingsPageMeta { isAvailable?: (ctx: SettingsRuntimeContext) => boolean; } -export const SETTINGS_GROUP_LABELS: Record = { - appearance: 'Appearance', - projects: 'Projects', - general: 'General', - opencode: 'OpenCode', - git: 'Git', - skills: 'Skills', - usage: 'Usage', - advanced: 'Advanced', -}; - export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'home', @@ -209,7 +198,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'about', title: 'About', group: 'advanced', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile }, ] as const; -export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { +const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { sessions: 'sessions', agents: 'agents', commands: 'commands', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 9c9f1c89..3d24931b 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -2,7 +2,7 @@ import type { I18nKey } from '@/lib/i18n/store'; import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata'; import { getSettingsPageMeta } from './metadata'; -export interface SettingsSearchItem { +interface SettingsSearchItem { id: string; page: SettingsPageSlug; titleKey: I18nKey; @@ -17,12 +17,12 @@ export interface SettingsSearchResult extends SettingsSearchItem { pageTitle: string; } -export interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext { +interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext { isMobile: boolean; isDesktopLocalOrigin: boolean; } -export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ +const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ { id: 'appearance.language', page: 'appearance', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 62cdeca3..5640372b 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -1,8 +1,8 @@ import { isMacOS } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; -export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; -export type ShortcutKey = string; +type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; +type ShortcutKey = string; export type ShortcutCombo = string; export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; @@ -15,7 +15,7 @@ export interface ShortcutAction { customizable?: boolean; } -export interface ParsedShortcut { +interface ParsedShortcut { modifiers: Set; key: ShortcutKey; } @@ -417,7 +417,7 @@ export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { return [...orderedModifiers, key].filter(Boolean).join('+'); } -export function isValidShortcutCombo(combo: ShortcutCombo): boolean { +function isValidShortcutCombo(combo: ShortcutCombo): boolean { if (isUnassignedShortcut(combo)) { return true; } @@ -426,7 +426,7 @@ export function isValidShortcutCombo(combo: ShortcutCombo): boolean { return parsed.key.trim().length > 0; } -export function parseShortcut(combo: ShortcutCombo): ParsedShortcut { +function parseShortcut(combo: ShortcutCombo): ParsedShortcut { if (isUnassignedShortcut(combo)) { return { modifiers: new Set(), key: UNASSIGNED_SHORTCUT }; } @@ -479,10 +479,6 @@ export function getShortcutAction(id: string): ShortcutAction | undefined { return SHORTCUT_ACTIONS.find((action) => action.id === id); } -export function getAllShortcutActions(): ReadonlyArray { - return SHORTCUT_ACTIONS; -} - export function getCustomizableShortcutActions(): ReadonlyArray { return SHORTCUT_ACTIONS.filter((action) => action.customizable === true); } @@ -515,17 +511,6 @@ export function getEffectiveShortcutCombo( return action.defaultCombo; } -export function getEffectiveShortcutLabel( - actionId: string, - overrides?: Record -): string { - const combo = getEffectiveShortcutCombo(actionId, overrides); - if (!combo) { - return ''; - } - return formatShortcutForDisplay(combo); -} - export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { if (isUnassignedShortcut(combo)) { return false; @@ -607,14 +592,6 @@ export function eventMatchesShortcut( return eventKey === expectedKey; } -export function getShortcutLabel(id: string): string { - const action = getShortcutAction(id); - if (!action) return ''; - - const displayCombo = formatShortcutForDisplay(action.defaultCombo); - return `${displayCombo} - ${action.label}`; -} - export function getModifierLabel(): string { return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; } diff --git a/packages/ui/src/lib/startupTrace.ts b/packages/ui/src/lib/startupTrace.ts index 4034a223..3fa03629 100644 --- a/packages/ui/src/lib/startupTrace.ts +++ b/packages/ui/src/lib/startupTrace.ts @@ -1,4 +1,4 @@ -export type StartupTraceEvent = { +type StartupTraceEvent = { t: number; name: string; data?: Record; @@ -43,7 +43,7 @@ export const markStartupTrace = (name: string, data?: Record) = } }; -export const getStartupTraceSummary = () => { +const getStartupTraceSummary = () => { const trace = typeof window !== 'undefined' ? window.__OPENCHAMBER_STARTUP_TRACE__ ?? [] : []; const readyIndex = trace.findIndex((event) => event.name === 'ModelControls:ready'); const endIndex = readyIndex >= 0 ? Math.min(trace.length, readyIndex + 8) : trace.length; diff --git a/packages/ui/src/lib/terminal/SerializeAddon.ts b/packages/ui/src/lib/terminal/SerializeAddon.ts deleted file mode 100644 index 6d720435..00000000 --- a/packages/ui/src/lib/terminal/SerializeAddon.ts +++ /dev/null @@ -1,497 +0,0 @@ -/** - * SerializeAddon for ghostty-web - * - * Port of xterm.js addon-serialize for ghostty-web terminal. - * Enables serialization of terminal contents to restore state after reconnection. - * - * Features: - * - ANSI color preservation (16-color, 256-color, RGB) - * - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim) - * - Scrollback support with configurable limits - * - Round-trip compatibility - * - Cursor positioning - */ - -import type { Terminal as GhosttyTerminal } from 'ghostty-web'; - -// Constants for ANSI escape codes -const C0 = { - ESC: '\u001b', -}; - -const SGR = { - RESET: 0, - BOLD: 1, - DIM: 2, - ITALIC: 3, - UNDERLINE: 4, - SLOW_BLINK: 5, - RAPID_BLINK: 6, - INVERSE: 7, - INVISIBLE: 8, - STRIKETHROUGH: 9, - NORMAL_INTENSITY: 22, - NO_ITALIC: 23, - NO_UNDERLINE: 24, - NO_BLINK: 25, - NO_INVERSE: 27, - VISIBLE: 28, - NO_STRIKETHROUGH: 29, - FG_DEFAULT: 39, - BG_DEFAULT: 49, -}; - -export interface SerializeOptions { - /** - * The row range to serialize. When an explicit range is specified, the cursor - * will get its final repositioning. - */ - range?: { - start: number; - end: number; - }; - - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. When not specified, all available - * rows in the scrollback buffer will be serialized. - */ - scrollback?: number; - - /** - * Whether to exclude the terminal modes from the serialization. - * Default: false - */ - excludeModes?: boolean; - - /** - * Whether to exclude the alt buffer from the serialization. - * Default: false - */ - excludeAltBuffer?: boolean; -} - -export interface TextSerializeOptions { - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. - */ - scrollback?: number; - - /** - * Whether to trim trailing whitespace from lines. - * Default: true - */ - trimWhitespace?: boolean; -} - -interface CellState { - fg: number | null; - bg: number | null; - bold: boolean; - dim: boolean; - italic: boolean; - underline: boolean; - blink: boolean; - inverse: boolean; - invisible: boolean; - strikethrough: boolean; -} - -const NULL_CELL_STATE: CellState = { - fg: null, - bg: null, - bold: false, - dim: false, - italic: false, - underline: false, - blink: false, - inverse: false, - invisible: false, - strikethrough: false, -}; - -/** - * SerializeAddon for ghostty-web terminal - */ -export class SerializeAddon { - private _terminal: GhosttyTerminal | undefined; - - /** - * Activate the addon - */ - activate(terminal: GhosttyTerminal): void { - this._terminal = terminal; - } - - /** - * Dispose the addon - */ - dispose(): void { - this._terminal = undefined; - } - - /** - * Serialize the terminal buffer to ANSI escape sequences - */ - serialize(options: SerializeOptions = {}): string { - if (!this._terminal) { - throw new Error('SerializeAddon not activated'); - } - - const buffer = this._terminal.buffer.active; - if (!buffer) { - return ''; - } - - const result: string[] = []; - let currentState: CellState = { ...NULL_CELL_STATE }; - - // Determine range to serialize - const scrollbackLimit = options.scrollback ?? buffer.length; - - let startRow: number; - let endRow: number; - - if (options.range) { - startRow = options.range.start; - endRow = options.range.end; - } else { - // Serialize scrollback + viewport - const totalRows = buffer.length; - const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY); - startRow = Math.max(0, buffer.baseY - scrollbackRows); - endRow = buffer.baseY + buffer.cursorY; - } - - // Clamp to valid range - startRow = Math.max(0, startRow); - endRow = Math.min(buffer.length - 1, endRow); - - for (let y = startRow; y <= endRow; y++) { - const line = buffer.getLine(y); - if (!line) { - result.push('\r\n'); - continue; - } - - let lineContent = ''; - let lastNonSpaceCol = -1; - - // Find the last non-space column - for (let x = line.length - 1; x >= 0; x--) { - const cell = line.getCell(x); - if (cell) { - const char = this._getCellChar(cell); - if (char !== ' ' && char !== '') { - lastNonSpaceCol = x; - break; - } - } - } - - // Serialize each cell up to the last non-space - for (let x = 0; x <= lastNonSpaceCol; x++) { - const cell = line.getCell(x); - if (!cell) { - lineContent += ' '; - continue; - } - - // Get cell attributes and generate SGR sequences if needed - const newState = this._getCellState(cell); - const sgrSequences = this._generateSgrDiff(currentState, newState); - if (sgrSequences) { - lineContent += sgrSequences; - currentState = newState; - } - - // Get character - const char = this._getCellChar(cell); - lineContent += char || ' '; - } - - // Reset attributes at end of line if any were set - if (this._hasAttributes(currentState)) { - lineContent += `${C0.ESC}[${SGR.RESET}m`; - currentState = { ...NULL_CELL_STATE }; - } - - result.push(lineContent); - - // Add newline unless it's the last row with cursor - if (y < endRow) { - result.push('\r\n'); - } - } - - // Position cursor - const cursorY = buffer.cursorY; - const cursorX = buffer.cursorX; - if (cursorY >= 0 && cursorX >= 0) { - // Use CUP (Cursor Position) to move cursor to correct position - // CUP is 1-based, so add 1 to both coordinates - const relativeY = cursorY - (endRow - buffer.baseY); - if (relativeY !== 0 || cursorX !== 0) { - result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`); - } - } - - return result.join(''); - } - - /** - * Serialize the terminal buffer to plain text (no escape sequences) - */ - serializeAsText(options: TextSerializeOptions = {}): string { - if (!this._terminal) { - throw new Error('SerializeAddon not activated'); - } - - const buffer = this._terminal.buffer.active; - if (!buffer) { - return ''; - } - - const trimWhitespace = options.trimWhitespace ?? true; - const scrollbackLimit = options.scrollback ?? buffer.length; - - const result: string[] = []; - - // Determine range - const totalRows = buffer.length; - const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY); - const startRow = Math.max(0, buffer.baseY - scrollbackRows); - const endRow = buffer.baseY + buffer.cursorY; - - for (let y = startRow; y <= endRow; y++) { - const line = buffer.getLine(y); - if (!line) { - result.push(''); - continue; - } - - let lineContent = ''; - for (let x = 0; x < line.length; x++) { - const cell = line.getCell(x); - if (cell) { - const char = this._getCellChar(cell); - lineContent += char || ' '; - } else { - lineContent += ' '; - } - } - - if (trimWhitespace) { - lineContent = lineContent.trimEnd(); - } - - result.push(lineContent); - } - - return result.join('\n'); - } - - /** - * Get the character from a cell, handling wide characters and special codepoints - */ - private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string { - // Try getChars() first (ghostty-web standard) - if (typeof cell.getChars === 'function') { - const chars = cell.getChars(); - if (chars) return chars; - } - - // Try getCodepoint() - if (typeof cell.getCodepoint === 'function') { - const codepoint = cell.getCodepoint(); - if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF && - !(codepoint >= 0xD800 && codepoint <= 0xDFFF)) { - return String.fromCodePoint(codepoint); - } - } - - // Fallback - return ' '; - } - - /** - * Get the state of a cell (colors and attributes) - */ - private _getCellState(cell: { - getFgColor?: () => number; - getBgColor?: () => number; - isBold?: () => boolean | number; - isDim?: () => boolean | number; - isFaint?: () => boolean | number; - isItalic?: () => boolean | number; - isUnderline?: () => boolean | number; - isBlink?: () => boolean | number; - isInverse?: () => boolean | number; - isInvisible?: () => boolean | number; - isStrikethrough?: () => boolean | number; - }): CellState { - const state: CellState = { ...NULL_CELL_STATE }; - - // Get foreground color - if (typeof cell.getFgColor === 'function') { - const fg = cell.getFgColor(); - if (fg !== undefined && fg !== null && fg !== -1) { - state.fg = fg; - } - } - - // Get background color - if (typeof cell.getBgColor === 'function') { - const bg = cell.getBgColor(); - if (bg !== undefined && bg !== null && bg !== -1) { - state.bg = bg; - } - } - - // Get attributes - if (typeof cell.isBold === 'function') { - state.bold = !!cell.isBold(); - } - if (typeof cell.isDim === 'function') { - state.dim = !!cell.isDim(); - } else if (typeof cell.isFaint === 'function') { - state.dim = !!cell.isFaint(); - } - if (typeof cell.isItalic === 'function') { - state.italic = !!cell.isItalic(); - } - if (typeof cell.isUnderline === 'function') { - state.underline = !!cell.isUnderline(); - } - if (typeof cell.isBlink === 'function') { - state.blink = !!cell.isBlink(); - } - if (typeof cell.isInverse === 'function') { - state.inverse = !!cell.isInverse(); - } - if (typeof cell.isInvisible === 'function') { - state.invisible = !!cell.isInvisible(); - } - if (typeof cell.isStrikethrough === 'function') { - state.strikethrough = !!cell.isStrikethrough(); - } - - return state; - } - - /** - * Generate SGR escape sequences for the difference between two cell states - */ - private _generateSgrDiff(from: CellState, to: CellState): string | null { - const codes: number[] = []; - - // Check if we need a full reset - const needsReset = - (from.bold && !to.bold) || - (from.dim && !to.dim) || - (from.italic && !to.italic) || - (from.underline && !to.underline) || - (from.blink && !to.blink) || - (from.inverse && !to.inverse) || - (from.invisible && !to.invisible) || - (from.strikethrough && !to.strikethrough); - - if (needsReset) { - codes.push(SGR.RESET); - // After reset, we need to re-apply all 'to' attributes - if (to.bold) codes.push(SGR.BOLD); - if (to.dim) codes.push(SGR.DIM); - if (to.italic) codes.push(SGR.ITALIC); - if (to.underline) codes.push(SGR.UNDERLINE); - if (to.blink) codes.push(SGR.SLOW_BLINK); - if (to.inverse) codes.push(SGR.INVERSE); - if (to.invisible) codes.push(SGR.INVISIBLE); - if (to.strikethrough) codes.push(SGR.STRIKETHROUGH); - - // Re-apply colors - if (to.fg !== null) { - this._appendColorCode(codes, to.fg, true); - } - if (to.bg !== null) { - this._appendColorCode(codes, to.bg, false); - } - } else { - // Apply only changed attributes - if (!from.bold && to.bold) codes.push(SGR.BOLD); - if (!from.dim && to.dim) codes.push(SGR.DIM); - if (!from.italic && to.italic) codes.push(SGR.ITALIC); - if (!from.underline && to.underline) codes.push(SGR.UNDERLINE); - if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK); - if (!from.inverse && to.inverse) codes.push(SGR.INVERSE); - if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE); - if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH); - - // Handle color changes - if (from.fg !== to.fg) { - if (to.fg === null) { - codes.push(SGR.FG_DEFAULT); - } else { - this._appendColorCode(codes, to.fg, true); - } - } - if (from.bg !== to.bg) { - if (to.bg === null) { - codes.push(SGR.BG_DEFAULT); - } else { - this._appendColorCode(codes, to.bg, false); - } - } - } - - if (codes.length === 0) { - return null; - } - - return `${C0.ESC}[${codes.join(';')}m`; - } - - /** - * Append color code to the codes array - */ - private _appendColorCode(codes: number[], color: number, isForeground: boolean): void { - const base = isForeground ? 30 : 40; - const extBase = isForeground ? 38 : 48; - - if (color < 8) { - // Basic 8 colors - codes.push(base + color); - } else if (color < 16) { - // Bright 8 colors - codes.push(base + 60 + (color - 8)); - } else if (color < 256) { - // 256-color palette - codes.push(extBase, 5, color); - } else { - // RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000 - const rgb = color - 0x1000000; - const r = (rgb >> 16) & 0xFF; - const g = (rgb >> 8) & 0xFF; - const b = rgb & 0xFF; - codes.push(extBase, 2, r, g, b); - } - } - - /** - * Check if the state has any attributes set - */ - private _hasAttributes(state: CellState): boolean { - return ( - state.fg !== null || - state.bg !== null || - state.bold || - state.dim || - state.italic || - state.underline || - state.blink || - state.inverse || - state.invisible || - state.strikethrough - ); - } -} diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 144d7c94..8eafe697 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,13 +1,13 @@ import { getRuntimeUrlResolver } from './runtime-url'; import { runtimeFetch } from './runtime-fetch'; -export interface TerminalWebSocketDescriptor { +interface TerminalWebSocketDescriptor { path: string; v?: number; enc?: string; } -export interface TerminalTransportCapability { +interface TerminalTransportCapability { preferred?: 'ws' | 'http' | 'sse'; transports?: Array<'ws' | 'http' | 'sse'>; ws?: TerminalWebSocketDescriptor; diff --git a/packages/ui/src/lib/terminalTheme.ts b/packages/ui/src/lib/terminalTheme.ts index 8f6f4844..5e9c7b14 100644 --- a/packages/ui/src/lib/terminalTheme.ts +++ b/packages/ui/src/lib/terminalTheme.ts @@ -62,35 +62,6 @@ export function convertThemeToXterm(theme: Theme): TerminalTheme { }; } -export function getTerminalOptions( - fontFamily: string, - fontSize: number, - theme: TerminalTheme -) { - - const powerlineFallbacks = - '"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace'; - const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`; - - return { - fontFamily: augmentedFontFamily, - fontSize, - lineHeight: 1, - cursorBlink: false, - cursorStyle: 'bar' as const, - theme, - allowTransparency: false, - scrollback: 10_000, - minimumContrastRatio: 1, - fastScrollModifier: 'shift' as const, - fastScrollSensitivity: 5, - scrollSensitivity: 3, - macOptionIsMeta: true, - macOptionClickForcesSelection: false, - rightClickSelectsWord: true, - }; -} - /** * Get terminal options for Ghostty Web terminal */ diff --git a/packages/ui/src/lib/theme/themes/index.ts b/packages/ui/src/lib/theme/themes/index.ts index a70a31f5..7fc83efb 100644 --- a/packages/ui/src/lib/theme/themes/index.ts +++ b/packages/ui/src/lib/theme/themes/index.ts @@ -6,10 +6,10 @@ import flexokiDarkRaw from './flexoki-dark.json'; import openchamberLightRaw from './fields-of-the-shire-light.json'; import openchamberDarkRaw from './fields-of-the-shire-dark.json'; -export const flexokiLightTheme = withPrColors(flexokiLightRaw as Theme); -export const flexokiDarkTheme = withPrColors(flexokiDarkRaw as Theme); -export const openchamberLightTheme = withPrColors(openchamberLightRaw as Theme); -export const openchamberDarkTheme = withPrColors(openchamberDarkRaw as Theme); +const flexokiLightTheme = withPrColors(flexokiLightRaw as Theme); +const flexokiDarkTheme = withPrColors(flexokiDarkRaw as Theme); +const openchamberLightTheme = withPrColors(openchamberLightRaw as Theme); +const openchamberDarkTheme = withPrColors(openchamberDarkRaw as Theme); export const DEFAULT_LIGHT_THEME_ID = 'flexoki-light' as const; export const DEFAULT_DARK_THEME_ID = 'flexoki-dark' as const; diff --git a/packages/ui/src/lib/theme/vscode/adapter.ts b/packages/ui/src/lib/theme/vscode/adapter.ts index f657ad30..b7136720 100644 --- a/packages/ui/src/lib/theme/vscode/adapter.ts +++ b/packages/ui/src/lib/theme/vscode/adapter.ts @@ -4,7 +4,7 @@ import { getDefaultTheme } from '@/lib/theme/themes'; export type VSCodeThemeKind = 'light' | 'dark' | 'high-contrast'; -export type VSCodeThemeColorToken = +type VSCodeThemeColorToken = // Editor core | 'editor.background' | 'editor.foreground' diff --git a/packages/ui/src/lib/timeFormat.ts b/packages/ui/src/lib/timeFormat.ts index a20be132..cb10546c 100644 --- a/packages/ui/src/lib/timeFormat.ts +++ b/packages/ui/src/lib/timeFormat.ts @@ -9,21 +9,6 @@ const getHour12Option = (preference: TimeFormatPreference): boolean | undefined return undefined; }; -export const getUses24HourForPreference = (preference: TimeFormatPreference, locale: string): boolean => { - if (preference === '24h') return true; - if (preference === '12h') return false; - - try { - const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions(); - if (typeof options.hour12 === 'boolean') { - return !options.hour12; - } - return options.hourCycle === 'h23' || options.hourCycle === 'h24'; - } catch { - return true; - } -}; - export const formatTimeForPreference = ( timestamp: number | Date, preference: TimeFormatPreference, diff --git a/packages/ui/src/lib/toolHelpers.ts b/packages/ui/src/lib/toolHelpers.ts index 79f77eff..0dabff8d 100644 --- a/packages/ui/src/lib/toolHelpers.ts +++ b/packages/ui/src/lib/toolHelpers.ts @@ -11,7 +11,7 @@ export interface ToolMetadata { category: 'file' | 'search' | 'code' | 'system' | 'ai' | 'web'; } -export const TOOL_METADATA: Record = { +const TOOL_METADATA: Record = { read: { displayName: 'Read File', diff --git a/packages/ui/src/lib/toolStatus.ts b/packages/ui/src/lib/toolStatus.ts deleted file mode 100644 index 0fb1e954..00000000 --- a/packages/ui/src/lib/toolStatus.ts +++ /dev/null @@ -1,83 +0,0 @@ -const ACTIVE_TOOL_STATUSES = new Set([ - 'pending', - 'running', - 'started', - 'inprogress', - 'processing', - 'executing', -]); - -const FINAL_TOOL_STATUSES = new Set([ - 'completed', - 'complete', - 'error', - 'failed', - 'aborted', - 'timeout', - 'timedout', - 'done', - 'cancelled', - 'canceled', -]); - -const readTimestamp = (value: unknown): number | undefined => { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -}; - -export const normalizeToolStatus = (status: unknown): string | undefined => { - if (typeof status !== 'string') { - return undefined; - } - - const normalized = status.toLowerCase().trim().replace(/[\s_-]+/g, ''); - return normalized.length > 0 ? normalized : undefined; -}; - -export const isActiveToolStatus = (status: unknown): boolean => { - const normalized = normalizeToolStatus(status); - return normalized ? ACTIVE_TOOL_STATUSES.has(normalized) : false; -}; - -export const isFinalToolStatus = (status: unknown): boolean => { - const normalized = normalizeToolStatus(status); - return normalized ? FINAL_TOOL_STATUSES.has(normalized) : false; -}; - -export type ToolLifecycleState = { - status?: string; - start?: number; - end?: number; - hasStarted: boolean; - hasEnded: boolean; - isStatusActive: boolean; - isStatusFinal: boolean; - isInFlight: boolean; - isFinalized: boolean; -}; - -export const getToolLifecycleState = ( - state?: { status?: unknown; time?: { start?: unknown; end?: unknown } } | null, -): ToolLifecycleState => { - const status = normalizeToolStatus(state?.status); - const start = readTimestamp(state?.time?.start); - const rawEnd = readTimestamp(state?.time?.end); - const hasEnded = typeof rawEnd === 'number' && (typeof start !== 'number' || rawEnd >= start); - const end = hasEnded ? rawEnd : undefined; - const isStatusActive = status ? ACTIVE_TOOL_STATUSES.has(status) : false; - const isStatusFinal = status ? FINAL_TOOL_STATUSES.has(status) : false; - const isUnknownNonFinal = status ? !isStatusActive && !isStatusFinal : true; - const isInFlight = !hasEnded && (isStatusActive || isUnknownNonFinal); - const isFinalized = hasEnded || isStatusFinal; - - return { - status, - start, - end, - hasStarted: typeof start === 'number', - hasEnded, - isStatusActive, - isStatusFinal, - isInFlight, - isFinalized, - }; -}; diff --git a/packages/ui/src/lib/typography.ts b/packages/ui/src/lib/typography.ts index 7b4b43cb..1df7a992 100644 --- a/packages/ui/src/lib/typography.ts +++ b/packages/ui/src/lib/typography.ts @@ -7,28 +7,6 @@ export const SEMANTIC_TYPOGRAPHY = { micro: '0.875rem', } as const; -export const FONT_SIZE_SCALES = { - small: { - markdown: '0.875rem', - code: '0.8125rem', - uiHeader: '0.875rem', - uiLabel: '0.8125rem', - meta: '0.8125rem', - micro: '0.75rem', - }, - medium: SEMANTIC_TYPOGRAPHY, - large: { - markdown: '1rem', - code: '0.9375rem', - uiHeader: '1rem', - uiLabel: '0.9375rem', - meta: '0.9375rem', - micro: '0.9375rem', - }, -} as const; - -export type FontSizeOption = keyof typeof FONT_SIZE_SCALES; - export const VSCODE_TYPOGRAPHY = { // Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density. markdown: '0.9063rem', @@ -39,35 +17,12 @@ export const VSCODE_TYPOGRAPHY = { micro: '0.7813rem', } as const; -export const SEMANTIC_TYPOGRAPHY_CSS = { - '--text-markdown': SEMANTIC_TYPOGRAPHY.markdown, - '--text-code': SEMANTIC_TYPOGRAPHY.code, - '--text-ui-header': SEMANTIC_TYPOGRAPHY.uiHeader, - '--text-ui-label': SEMANTIC_TYPOGRAPHY.uiLabel, - '--text-meta': SEMANTIC_TYPOGRAPHY.meta, - '--text-micro': SEMANTIC_TYPOGRAPHY.micro, -} as const; - -export const TYPOGRAPHY_CLASSES = { - markdown: 'typography-markdown', - code: 'typography-code', - uiHeader: 'typography-ui-header', - uiLabel: 'typography-ui-label', - meta: 'typography-meta', - micro: 'typography-micro', -} as const; - export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY; -export type TypographyClassKey = keyof typeof TYPOGRAPHY_CLASSES; export function getTypographyVariable(key: SemanticTypographyKey): string { return `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`; } -export function getTypographyClass(key: TypographyClassKey): string { - return TYPOGRAPHY_CLASSES[key]; -} - export const typography = { semanticMarkdown: { @@ -248,22 +203,6 @@ export const typography = { }, }; -export function getTypographyStyle(path: string, fallback?: React.CSSProperties): React.CSSProperties { - const parts = path.split('.'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let current: any = typography; - - for (const part of parts) { - if (current && current[part]) { - current = current[part]; - } else { - return fallback || {}; - } - } - - return current || fallback || {}; -} - export const toolDisplayStyles = { padding: { @@ -309,49 +248,3 @@ export const toolDisplayStyles = { ...typography.tool.inline, }), }; - -export const typographyClasses = { - - 'heading-1': 'typography-h1', - 'heading-2': 'typography-h2', - 'heading-3': 'typography-h3', - 'heading-4': 'typography-h4', - 'heading-5': 'typography-h5', - 'heading-6': 'typography-h6', - - 'ui-button': 'typography-ui-button', - 'ui-button-small': 'typography-ui-button-small', - 'ui-button-large': 'typography-ui-button-large', - 'ui-label': 'typography-ui-label', - 'ui-caption': 'typography-ui-caption', - 'ui-badge': 'typography-ui-badge', - 'ui-tooltip': 'typography-ui-tooltip', - 'ui-input': 'typography-ui-input', - 'ui-helper': 'typography-ui-helper-text', - - 'code-inline': 'typography-code-inline', - 'code-block': 'typography-code-block', - 'code-line-numbers': 'typography-code-line-numbers', - - 'markdown-h1': 'typography-markdown-h1', - 'markdown-h2': 'typography-markdown-h2', - 'markdown-h3': 'typography-markdown-h3', - 'markdown-h4': 'typography-markdown-h4', - 'markdown-h5': 'typography-markdown-h5', - 'markdown-h6': 'typography-markdown-h6', - 'markdown-body': 'typography-markdown-body', - 'markdown-body-small': 'typography-markdown-body-small', - 'markdown-body-large': 'typography-markdown-body-large', - 'markdown-blockquote': 'typography-markdown-blockquote', - 'markdown-list': 'typography-markdown-list', - 'markdown-link': 'typography-markdown-link', - 'markdown-code': 'typography-markdown-code', - 'markdown-code-block': 'typography-markdown-code-block', - - 'semantic-markdown': 'typography-markdown', - 'semantic-code': 'typography-code', - 'semantic-ui-header': 'typography-ui-header', - 'semantic-ui-label': 'typography-ui-label', - 'semantic-meta': 'typography-meta', - 'semantic-micro': 'typography-micro', -}; diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index fe7f1087..393f43a8 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -17,7 +17,7 @@ export const isMacOS = (): boolean => { return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }; -export const isWindows = (): boolean => { +const isWindows = (): boolean => { if (typeof navigator === 'undefined') return false; return /Windows/.test(navigator.userAgent || ''); }; diff --git a/packages/ui/src/lib/voice/audioStreamService.ts b/packages/ui/src/lib/voice/audioStreamService.ts index 36b6602d..adfaa137 100644 --- a/packages/ui/src/lib/voice/audioStreamService.ts +++ b/packages/ui/src/lib/voice/audioStreamService.ts @@ -20,10 +20,10 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; -export type SpeechResultCallback = (text: string, isFinal: boolean) => void; -export type ErrorCallback = (error: string) => void; +type SpeechResultCallback = (text: string, isFinal: boolean) => void; +type ErrorCallback = (error: string) => void; -export interface AudioStreamConfig { +interface AudioStreamConfig { /** Base URL of the OpenAI-compatible STT server (e.g. http://localhost:8001/v1) */ baseURL: string; /** Whisper-compatible model name */ @@ -395,4 +395,3 @@ class AudioStreamService { } export const audioStreamService = new AudioStreamService(); -export { AudioStreamService }; diff --git a/packages/ui/src/lib/voice/browserVoiceService.ts b/packages/ui/src/lib/voice/browserVoiceService.ts index 439d946b..342665e7 100644 --- a/packages/ui/src/lib/voice/browserVoiceService.ts +++ b/packages/ui/src/lib/voice/browserVoiceService.ts @@ -34,9 +34,9 @@ declare global { } // Callback types -export type SpeechResultCallback = (text: string, isFinal: boolean) => void; -export type SpeechEndCallback = () => void; -export type ErrorCallback = (error: string) => void; +type SpeechResultCallback = (text: string, isFinal: boolean) => void; +type SpeechEndCallback = () => void; +type ErrorCallback = (error: string) => void; /** * Browser Voice Service class @@ -234,7 +234,7 @@ class BrowserVoiceService { this.isListening = true; this.restartOnEnd = true; }; - + this.recognition.onaudiostart = () => { console.log('[BrowserVoiceService] Audio recording started'); }; @@ -652,6 +652,3 @@ class BrowserVoiceService { // Export singleton instance export const browserVoiceService = new BrowserVoiceService(); - -// Also export the class for testing/customization -export { BrowserVoiceService }; diff --git a/packages/ui/src/lib/voice/contextFormatters.ts b/packages/ui/src/lib/voice/contextFormatters.ts index 513eb6ac..a4d22978 100644 --- a/packages/ui/src/lib/voice/contextFormatters.ts +++ b/packages/ui/src/lib/voice/contextFormatters.ts @@ -29,7 +29,7 @@ export interface VoiceMessage { * @param message - The message to format * @returns Formatted text for voice, or null if should not be spoken */ -export function formatMessage(message: VoiceMessage): string | null { +function formatMessage(message: VoiceMessage): string | null { // Handle edge cases if (!message || typeof message.content !== "string") { return null; diff --git a/packages/ui/src/lib/voice/index.ts b/packages/ui/src/lib/voice/index.ts index ebff495e..807ea190 100644 --- a/packages/ui/src/lib/voice/index.ts +++ b/packages/ui/src/lib/voice/index.ts @@ -1,36 +1,17 @@ /** * Voice module barrel export - * Provides clean import path for voice configuration and client tools + * Provides a clean import path for voice session hooks. * * @example * ```typescript - * import { VOICE_CONFIG, realtimeClientTools, voiceHooks } from '@/lib/voice'; + * import { voiceHooks } from '@/lib/voice'; * ``` */ -// Configuration -export { VOICE_CONFIG } from "./voiceConfig"; - -// Client tools for ElevenLabs voice agent -export { realtimeClientTools } from "./realtimeClientTools"; -export type { RealtimeClientTools } from "./realtimeClientTools"; - // Voice session registry (from voiceSession.ts) export { - registerVoiceSession, - unregisterVoiceSession, - getVoiceSession, isVoiceSessionStarted, } from "./voiceSession"; // Voice hooks for session-to-voice event routing (from voiceHooks.ts) export { voiceHooks } from "./voiceHooks"; - -// Context formatters for voice-native output -export { - formatMessage, - formatNewMessages, - formatPermissionRequest, - formatReadyEvent, - type VoiceMessage, -} from "./contextFormatters"; diff --git a/packages/ui/src/lib/voice/realtimeClientTools.ts b/packages/ui/src/lib/voice/realtimeClientTools.ts deleted file mode 100644 index b11ca657..00000000 --- a/packages/ui/src/lib/voice/realtimeClientTools.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { z } from "zod"; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useConfigStore } from "@/stores/useConfigStore"; -import { getSyncPermissions } from "@/sync/sync-refs"; -import { respondToPermission } from "@/sync/session-actions"; - -/** - * Static client tools for the realtime voice interface. - * These tools allow the voice agent to interact with Claude Code. - */ -export const realtimeClientTools = { - /** - * Send a message to Claude Code via the current session. - * Validates parameters with Zod and returns status strings. - */ - messageClaudeCode: async (parameters: unknown): Promise => { - // Validate parameters with Zod - const schema = z.object({ - message: z.string().min(1, "Message cannot be empty"), - }); - - const parsed = schema.safeParse(parameters); - if (!parsed.success) { - console.error("[Voice] Invalid message parameter:", parsed.error); - return "error (invalid message parameter)"; - } - - // Get current session ID from store - const sessionId = useSessionUIStore.getState().currentSessionId; - if (!sessionId) { - console.error("[Voice] No active session"); - return "error (no active session)"; - } - - // Get current provider and model from config store - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - if (!currentProviderId || !currentModelId) { - console.error("[Voice] No provider/model selected"); - return "error (no provider or model selected)"; - } - - try { - console.log("[Voice] Sending message to session:", sessionId); - await useSessionUIStore - .getState() - .sendMessage( - parsed.data.message, - currentProviderId, - currentModelId, - currentAgentName ?? undefined, - undefined, - undefined, - undefined, - currentVariant ?? undefined, - ); - return "sent"; - } catch (error) { - console.error("[Voice] Failed to send message:", error); - return "error (failed to send message)"; - } - }, - - /** - * Process a permission request from voice. - * Validates decision with Zod enum and interacts with permission store. - */ - processPermissionRequest: async (parameters: unknown): Promise => { - // Validate parameters with Zod - const schema = z.object({ - decision: z.enum(["allow", "deny"]), - }); - - const parsed = schema.safeParse(parameters); - if (!parsed.success) { - console.error("[Voice] Invalid decision parameter:", parsed.error); - return "error (invalid decision parameter, expected 'allow' or 'deny')"; - } - - // Get current session ID from store - 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 = getSyncPermissions(sessionId); - if (!permissions || permissions.length === 0) { - console.error("[Voice] No pending permission requests"); - return "error (no pending permission request)"; - } - - // Get the first pending permission request - const request = permissions[0]; - if (!request) { - return "error (no pending permission request)"; - } - - try { - const decision = parsed.data.decision; - console.log(`[Voice] Processing permission request ${request.id}: ${decision}`); - - // Respond to the permission based on decision - const response: "once" | "always" | "reject" = decision === "allow" ? "once" : "reject"; - await respondToPermission(sessionId, request.id, response); - - return "done"; - } catch (error) { - console.error("[Voice] Failed to process permission:", error); - return `error (failed to ${parsed.data.decision} permission)`; - } - }, -}; - -/** Type for the realtime client tools */ -export type RealtimeClientTools = typeof realtimeClientTools; diff --git a/packages/ui/src/lib/voice/summarize.ts b/packages/ui/src/lib/voice/summarize.ts index d37eaa53..713bb2d1 100644 --- a/packages/ui/src/lib/voice/summarize.ts +++ b/packages/ui/src/lib/voice/summarize.ts @@ -1,114 +1,3 @@ -/** - * Text summarization utility - * - * Calls the server-side text summarization endpoint which uses - * the opencode.ai zen API with gpt-5-nano. - */ - -import { useConfigStore } from '@/stores/useConfigStore'; -import { runtimeFetch } from '@/lib/runtime-fetch'; - -/** - * Summarize text using the server-side zen API endpoint - * - * @param text - The text to summarize - * @param options - Optional configuration - * @returns The summarized text, or original text if summarization fails - */ -export async function summarizeText( - text: string, - options?: { - /** Character threshold - don't summarize if under this length */ - threshold?: number; - /** Max characters for the summary output */ - maxLength?: number; - /** Summarization mode */ - mode?: 'tts' | 'note'; - } -): Promise { - const store = useConfigStore.getState(); - const threshold = options?.threshold ?? store.summarizeCharacterThreshold; - const maxLength = options?.maxLength ?? store.summarizeMaxLength; - const mode = options?.mode ?? 'tts'; - const normalizedSource = text.replace(/\s+/g, ' ').trim(); - - // Don't summarize if text is under threshold - if (text.length <= threshold) { - if (mode === 'note') { - throw new Error('Note summarization threshold bypass is not allowed'); - } - return text; - } - - try { - const response = await runtimeFetch('/api/text/summarize', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ text, threshold, maxLength, mode }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`[summarize] HTTP error ${response.status}:`, errorText); - throw new Error(`Summarization failed: ${response.status}`); - } - - const data = await response.json() as { - summarized: boolean; - summary?: string; - reason?: string; - originalLength?: number; - summaryLength?: number; - }; - - if (typeof data.summary === 'string' && data.summary.trim().length > 0) { - const summary = data.summary.trim(); - if (mode === 'note') { - const normalizedSummary = summary.replace(/\s+/g, ' ').trim(); - if (normalizedSummary === normalizedSource) { - throw new Error('Note distillation returned source text unchanged'); - } - } - return summary; - } - - if (mode === 'note') { - throw new Error('Note summarization returned no distilled result'); - } - // Return original text if the server produced nothing usable - return text; - } catch (err) { - console.error('[summarize] Failed to summarize:', err); - if (mode === 'note') { - throw err instanceof Error ? err : new Error('Note summarization failed'); - } - // Return original text on error - return text; - } -} - -/** - * Check if text should be summarized based on settings - */ -export function shouldSummarize( - text: string, - context: 'message' | 'voice' -): boolean { - const store = useConfigStore.getState(); - - const isEnabled = context === 'message' - ? store.summarizeMessageTTS - : store.summarizeVoiceConversation; - - if (!isEnabled) { - return false; - } - - return text.length > store.summarizeCharacterThreshold; -} - /** * Client-side text sanitization for TTS output. * Removes markdown, URLs, file paths, and other non-speakable content. diff --git a/packages/ui/src/lib/voice/voiceConfig.ts b/packages/ui/src/lib/voice/voiceConfig.ts index 021215ac..d9d20004 100644 --- a/packages/ui/src/lib/voice/voiceConfig.ts +++ b/packages/ui/src/lib/voice/voiceConfig.ts @@ -30,6 +30,3 @@ export const VOICE_CONFIG = { /** Enable debug logging for voice context updates */ ENABLE_DEBUG_LOGGING: true, } as const; - -/** Type for VOICE_CONFIG keys */ -export type VoiceConfigKey = keyof typeof VOICE_CONFIG; diff --git a/packages/ui/src/lib/voice/voiceHooks.ts b/packages/ui/src/lib/voice/voiceHooks.ts index 95ebb6bd..f9a45573 100644 --- a/packages/ui/src/lib/voice/voiceHooks.ts +++ b/packages/ui/src/lib/voice/voiceHooks.ts @@ -24,14 +24,6 @@ import { } from "./contextFormatters"; import { getVoiceSession, isVoiceSessionStarted } from "./voiceSession"; -// Re-export registry functions from voiceSession.ts for convenience -export { - registerVoiceSession, - unregisterVoiceSession, - getVoiceSession, - isVoiceSessionStarted, -} from "./voiceSession"; - /** * Report a contextual update to the voice session * Internal helper that checks preconditions and handles errors diff --git a/packages/ui/src/lib/voice/voiceSession.ts b/packages/ui/src/lib/voice/voiceSession.ts index 1867f134..c5f31b8c 100644 --- a/packages/ui/src/lib/voice/voiceSession.ts +++ b/packages/ui/src/lib/voice/voiceSession.ts @@ -10,25 +10,7 @@ interface VoiceSession { * Global storage for the active voice session. * Used by voiceHooks to send contextual updates to the voice agent. */ -let activeVoiceSession: VoiceSession | null = null; - -/** - * Register a voice session for use by voiceHooks. - * Called by useVoice when a conversation is established. - */ -export function registerVoiceSession(session: VoiceSession): void { - activeVoiceSession = session; - console.log("[Voice] Session registered"); -} - -/** - * Unregister the active voice session. - * Called by useVoice when the session ends. - */ -export function unregisterVoiceSession(): void { - activeVoiceSession = null; - console.log("[Voice] Session unregistered"); -} +const activeVoiceSession: VoiceSession | null = null; /** * Get the currently registered voice session. diff --git a/packages/ui/src/lib/voice/wasmSttService.ts b/packages/ui/src/lib/voice/wasmSttService.ts index 7a2f6111..5102f972 100644 --- a/packages/ui/src/lib/voice/wasmSttService.ts +++ b/packages/ui/src/lib/voice/wasmSttService.ts @@ -48,8 +48,8 @@ export const WASM_MODELS: WasmModelInfo[] = [ }, ]; -export type SpeechResultCallback = (text: string, isFinal: boolean) => void; -export type ErrorCallback = (error: string) => void; +type SpeechResultCallback = (text: string, isFinal: boolean) => void; +type ErrorCallback = (error: string) => void; const VAD_POLL_MS = 80; const MIN_UTTERANCE_MS = 300; @@ -553,4 +553,3 @@ class WasmSttService { } export const wasmSttService = new WasmSttService(); -export { WasmSttService }; diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 1cc475e7..c93da014 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -285,173 +285,10 @@ export async function createWorktreeSession(): Promise { /** * Check if a worktree session is currently being created. */ -export function isCreatingWorktree(): boolean { - return isCreatingWorktreeSession; -} - export async function createWorktreeDraft(options?: { initialPrompt?: string; title?: string }): Promise { return createInstantWorktreeDraft(options); } -export async function createWorktreeOnly(): Promise { - if (isCreatingWorktreeSession) { - return null; - } - - const activeProject = useProjectsStore.getState().getActiveProject(); - if (!activeProject?.path) { - toast.error('No active project', { - description: 'Please select a project first.', - }); - return null; - } - - const projectDirectory = activeProject.path; - let isGitRepo = false; - try { - isGitRepo = await checkIsGitRepository(projectDirectory); - } catch { - // ignored - } - - if (!isGitRepo) { - toast.error('Not a Git repository', { - description: 'Worktrees can only be created in Git repositories.', - }); - return null; - } - - isCreatingWorktreeSession = true; - - try { - const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory }; - const preferredName = generateBranchName(); - const setupCommands = await getWorktreeSetupCommands(projectRef); - const metadata = await createWorktreeWithDefaults(projectRef, { - preferredName, - mode: 'new', - branchName: preferredName, - worktreeName: preferredName, - setupCommands, - returnAfterDirectoryCreated: true, - }); - - - return metadata.path; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree'; - toast.error('Failed to create worktree', { - description: message, - }); - return null; - } finally { - isCreatingWorktreeSession = false; - } -} - -/** - * Create a new session with a worktree for a specific branch. - * Unlike createWorktreeSession(), this allows specifying the project and branch explicitly. - * - * @param projectDirectory - The root directory of the git repository - * @param branchName - The name of the branch to create a worktree for - * @returns The created session, or null if creation failed - */ -export async function createWorktreeSessionForBranch( - projectDirectory: string, - branchName: string, - options?: { - kind?: 'pr' | 'standard'; - existingBranch?: string; - worktreeName?: string; - setUpstream?: boolean; - upstreamRemote?: string; - upstreamBranch?: string; - ensureRemoteName?: string; - ensureRemoteUrl?: string; - createdFromBranch?: string; - returnAfterDirectoryCreated?: boolean; - } -): Promise<{ id: string } | null> { - if (isCreatingWorktreeSession) { - return null; - } - - isCreatingWorktreeSession = true; - - try { - const projectRef = resolveProjectRef(projectDirectory); - if (!projectRef) { - throw new Error('Project is not registered in OpenChamber'); - } - - // Check if it's a git repo (root project path) - let isGitRepo = false; - try { - isGitRepo = await checkIsGitRepository(projectRef.path); - } catch { - // Ignore errors, treat as not a git repo - } - - if (!isGitRepo) { - toast.error('Not a Git repository', { - description: 'Worktrees can only be created in Git repositories.', - }); - return null; - } - - const setupCommands = await getWorktreeSetupCommands(projectRef); - const rootBranch = await getRootBranch(projectRef.path); - const metadata = await createWorktreeWithDefaults(projectRef, { - preferredName: branchName, - mode: 'existing', - existingBranch: options?.existingBranch || branchName, - branchName, - worktreeName: options?.worktreeName || branchName, - setUpstream: options?.setUpstream, - upstreamRemote: options?.upstreamRemote, - upstreamBranch: options?.upstreamBranch, - ensureRemoteName: options?.ensureRemoteName, - ensureRemoteUrl: options?.ensureRemoteUrl, - setupCommands, - returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, - }); - - const kind = options?.kind ?? 'standard'; - const createdMetadata = { - ...metadata, - createdFromBranch: options?.createdFromBranch || rootBranch, - kind, - }; - - await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path); - - // Create the session - const sessionStore = useSessionUIStore.getState(); - const session = await sessionStore.createSession(undefined, metadata.path); - if (!session) { - // Clean up the worktree if session creation failed - await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); - toast.error('Failed to create session', { - description: 'Could not create a session for the worktree.', - }); - return null; - } - - initializeSessionForWorktree(session.id, createdMetadata); - - return session; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree session'; - toast.error('Failed to create worktree', { - description: message, - }); - return null; - } finally { - isCreatingWorktreeSession = false; - } -} - /** * Create a worktree session for a new branch name. * Callers can still use startPoint for metadata or follow-up git operations. @@ -550,36 +387,3 @@ export async function createWorktreeSessionForNewBranch( isCreatingWorktreeSession = false; } } - -/** - * Same as createWorktreeSessionForNewBranch, but preserves the exact branch name. - * Use when the worktree must be tied to a specific ref (e.g. PR head ref). - */ -export async function createWorktreeSessionForNewBranchExact( - projectDirectory: string, - branchName: string, - startPoint: string, - options?: { - kind?: 'pr' | 'standard'; - worktreeName?: string; - setUpstream?: boolean; - upstreamRemote?: string; - upstreamBranch?: string; - ensureRemoteName?: string; - ensureRemoteUrl?: string; - createdFromBranch?: string; - returnAfterDirectoryCreated?: boolean; - } -): Promise<{ id: string; branch: string; path: string } | null> { - return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { - kind: options?.kind, - worktreeName: options?.worktreeName, - setUpstream: options?.setUpstream, - upstreamRemote: options?.upstreamRemote, - upstreamBranch: options?.upstreamBranch, - ensureRemoteName: options?.ensureRemoteName, - ensureRemoteUrl: options?.ensureRemoteUrl, - createdFromBranch: options?.createdFromBranch, - returnAfterDirectoryCreated: options?.returnAfterDirectoryCreated, - }); -} diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.ts b/packages/ui/src/lib/worktrees/worktreeCreate.ts index 2ed77db6..895cb1db 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -65,7 +65,7 @@ export const resolveRootTrackingRemote = async (projectDirectory: string): Promi return null; }; -export const resolveWorktreeUpstreamDefaults = async ( +const resolveWorktreeUpstreamDefaults = async ( projectDirectory: string, localBranch: string ): Promise<{ setUpstream: true; upstreamRemote: string; upstreamBranch: string } | null> => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index c66b7fe2..a33e5a0b 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -163,7 +163,7 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { return parts[parts.length - 1] ?? normalized; }; -export const buildSdkStartCommand = (args: { +const buildSdkStartCommand = (args: { projectDirectory: string; setupCommands: string[]; }): string | undefined => { @@ -181,7 +181,7 @@ export const buildSdkStartCommand = (args: { return joined.trim().length > 0 ? joined : undefined; }; -export const toCreatePayload = (args: { +const toCreatePayload = (args: { preferredName?: string; setupCommands?: string[]; mode?: 'new' | 'existing'; diff --git a/packages/ui/src/lib/worktrees/worktreeStatus.test.ts b/packages/ui/src/lib/worktrees/worktreeStatus.test.ts index d959fb56..c16ce16c 100644 --- a/packages/ui/src/lib/worktrees/worktreeStatus.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeStatus.test.ts @@ -9,10 +9,6 @@ let statusImpl: (directory: string) => { current: string } = () => ({ current: ' const resolveRootCalls: string[] = []; const statusCalls: string[] = []; -mock.module('@/lib/execCommands', () => ({ - execCommands: () => Promise.resolve({ success: false, results: [] }), -})); - mock.module('@/lib/gitApi', () => ({ getGitStatus: (directory: string) => { statusCalls.push(directory); diff --git a/packages/ui/src/stores/fileStore.ts b/packages/ui/src/stores/fileStore.ts deleted file mode 100644 index 05b9e191..00000000 --- a/packages/ui/src/stores/fileStore.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { AttachedFile } from "./types/sessionTypes"; -import { getSafeStorage } from "./utils/safeStorage"; -import { runtimeFetch } from "@/lib/runtime-fetch"; - -interface FileState { - attachedFiles: AttachedFile[]; -} - -interface FileActions { - addAttachedFile: (file: File) => Promise; - addServerFile: (path: string, name: string, content?: string) => Promise; - removeAttachedFile: (id: string) => void; - clearAttachedFiles: () => void; -} - -type FileStore = FileState & FileActions; - -const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024; - -const guessMimeTypeFromName = (filename: string): string => { - const name = (filename || "").toLowerCase(); - const ext = name.includes(".") ? name.split(".").pop() || "" : ""; - switch (ext) { - case "png": - return "image/png"; - case "jpg": - case "jpeg": - return "image/jpeg"; - case "gif": - return "image/gif"; - case "webp": - return "image/webp"; - case "svg": - return "image/svg+xml"; - case "bmp": - return "image/bmp"; - case "ico": - return "image/x-icon"; - case "pdf": - return "application/pdf"; - default: - return "application/octet-stream"; - } -}; - -const guessMimeType = (file: File): string => { - if (file.type && file.type.trim().length > 0) { - return file.type; - } - - const name = (file.name || "").toLowerCase(); - const ext = name.includes(".") ? name.split(".").pop() || "" : ""; - const noExtNames = new Set([ - "license", - "readme", - "changelog", - "notice", - "authors", - "copying", - ]); - - if (noExtNames.has(name)) return "text/plain"; - - switch (ext) { - case "md": - case "markdown": - return "text/markdown"; - case "txt": - return "text/plain"; - case "json": - return "application/json"; - case "yaml": - case "yml": - return "application/x-yaml"; - case "ts": - case "tsx": - case "js": - case "jsx": - case "mjs": - case "cjs": - case "py": - case "rb": - case "sh": - case "bash": - case "zsh": - return "text/plain"; - default: - return "application/octet-stream"; - } -}; - -const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim(); - -const toFileUrl = (inputPath: string): string => { - const normalized = normalizeServerPath(inputPath); - if (normalized.startsWith("file://")) { - return normalized; - } - - const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; - return `file://${encodeURI(withLeadingSlash)}`; -}; - -const readRawFileAsDataUrl = async (absolutePath: string): Promise => { - const response = await runtimeFetch("/api/fs/raw", { query: { path: absolutePath } }); - if (!response.ok) { - throw new Error(`Failed to read raw file: ${response.status}`); - } - const blob = await response.blob(); - return await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -}; - -export const useFileStore = create()( - - devtools( - persist( - (set, get) => ({ - - attachedFiles: [], - - addAttachedFile: async (file: File) => { - - const { attachedFiles } = get(); - const isDuplicate = attachedFiles.some((f) => f.filename === file.name && f.size === file.size); - if (isDuplicate) { - console.log(`File "${file.name}" is already attached`); - return; - } - - const maxSize = MAX_ATTACHMENT_SIZE; - if (file.size > maxSize) { - throw new Error(`File "${file.name}" is too large. Maximum size is 50MB.`); - } - - const allowedTypes = [ - "text/", - "application/json", - "application/xml", - "application/pdf", - "image/", - "video/", - "audio/", - "application/javascript", - "application/typescript", - "application/x-python", - "application/x-ruby", - "application/x-sh", - "application/yaml", - "application/octet-stream", - ]; - - const mimeType = guessMimeType(file); - const isAllowed = allowedTypes.some((type) => mimeType.startsWith(type) || mimeType === type || mimeType === ""); - - if (!isAllowed && mimeType !== "") { - console.warn(`File type "${mimeType}" might not be supported`); - } - - const reader = new FileReader(); - const rawDataUrl = await new Promise((resolve, reject) => { - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); - - const dataUrl = rawDataUrl.startsWith("data:") - ? rawDataUrl.replace(/^data:[^;]*/, `data:${mimeType}`) - : rawDataUrl; - - const extractFilename = (fullPath: string) => { - - const parts = fullPath.replace(/\\/g, "/").split("/"); - return parts[parts.length - 1] || fullPath; - }; - - const attachedFile: AttachedFile = { - id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - file, - dataUrl, - mimeType, - filename: extractFilename(file.name), - size: file.size, - source: "local", - }; - - set((state) => ({ - attachedFiles: [...state.attachedFiles, attachedFile], - })); - }, - - addServerFile: async (path: string, name: string, content?: string) => { - - const normalizedPath = normalizeServerPath(path); - const { attachedFiles } = get(); - const isDuplicate = attachedFiles.some((f) => normalizeServerPath(f.serverPath || "") === normalizedPath && f.source === "server"); - if (isDuplicate) { - console.log(`Server file "${name}" is already attached`); - return; - } - - const inferredMime = guessMimeTypeFromName(name); - const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream"; - - const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory"; - - let dataUrl = toFileUrl(normalizedPath); - if (shouldInlineBinary) { - try { - dataUrl = await readRawFileAsDataUrl(normalizedPath); - } catch (error) { - console.warn("Failed to inline binary server file, falling back to file://", error); - } - } - - const sizeBytes = typeof content === "string" - ? new TextEncoder().encode(content).length - : 0; - - const file = new File([], name, { type: safeMimeType }); - - const attachedFile: AttachedFile = { - id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - file, - dataUrl, - mimeType: safeMimeType, - filename: name, - size: sizeBytes, - source: "server", - serverPath: normalizedPath, - }; - - set((state) => ({ - attachedFiles: [...state.attachedFiles, attachedFile], - })); - }, - - removeAttachedFile: (id: string) => { - set((state) => ({ - attachedFiles: state.attachedFiles.filter((f) => f.id !== id), - })); - }, - - clearAttachedFiles: () => { - set({ attachedFiles: [] }); - }, - }), - { - name: "file-store", - storage: createJSONStorage(() => getSafeStorage()), - version: 3, - migrate: (persistedState) => { - const state = persistedState as { attachedFiles?: AttachedFile[] } | undefined; - return { attachedFiles: Array.isArray(state?.attachedFiles) ? state.attachedFiles : [] }; - }, - // Keep unsent draft attachments across restarts. - partialize: (state) => ({ - attachedFiles: state.attachedFiles, - }), - } - ), - { - name: "file-store", - } - ) -); diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index a7da6e67..56babca6 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -73,29 +73,6 @@ const unwrapSessionList = ( return result.data as GlobalSessionRecord[]; }; -export const readNextCursor = (response: unknown): number | null => { - return toNumber(readResponseHeader(response, "x-next-cursor")); -}; - -export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => { - if (!error || typeof error !== "object") { - return false; - } - - const value = error as { - status?: number; - response?: { status?: number }; - cause?: { status?: number; response?: { status?: number } }; - }; - - const status = value.status - ?? value.response?.status - ?? value.cause?.status - ?? value.cause?.response?.status; - - return status === 404; -}; - export async function listGlobalSessionPages( apiClient: OpencodeClient, options: { diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 57a53952..4ad681e9 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -1,7 +1,3 @@ -import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; -import type { PermissionRequest, PermissionResponse } from "@/types/permission"; -import type { QuestionRequest } from "@/types/question"; - export type SessionWorktreeAttachment = { worktreeRoot: string | null; cwd: string | null; @@ -31,31 +27,6 @@ export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full'; export type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed'; -export interface MessageStreamLifecycle { - phase: MessageStreamPhase; - startedAt: number; - lastUpdateAt: number; - completedAt?: number; -} - -export interface SessionMemoryState { - viewportAnchor: number; - isStreaming: boolean; - streamStartTime?: number; - lastAccessedAt: number; - backgroundMessageCount: number; - isZombie?: boolean; - totalAvailableMessages?: number; - loadedTurnCount?: number; - hasMoreAbove?: boolean; - hasMoreTurnsAbove?: boolean; - historyLoading?: boolean; - historyComplete?: boolean; - historyLimit?: number; - streamingCooldownUntil?: number; - lastUserMessageAt?: number; // Timestamp when user last sent a message -} - export interface SessionHistoryMeta { limit: number; complete: boolean; @@ -75,18 +46,14 @@ export interface SessionContextUsage { // Default message limit (can be overridden via settings). // Single value controls: fetch from server, active session ceiling, Load More chunk. // Background trim is derived automatically as Math.round(limit * 0.6). -export const DEFAULT_MESSAGE_LIMIT = 200; - -/** Timeout after which a session stuck in 'busy' or 'retry' with no SSE events is force-reset to idle. */ -export const STUCK_SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -export const MEMORY_CONSTANTS = { +const DEFAULT_MESSAGE_LIMIT = 200; +const MEMORY_CONSTANTS = { MAX_SESSIONS: 3, ZOMBIE_TIMEOUT: 10 * 60 * 1000, } as const; /** OpenCode parity: fixed page/window size for message history. */ -export const getMessageLimit = (): number => { +const getMessageLimit = (): number => { return DEFAULT_MESSAGE_LIMIT; }; @@ -95,7 +62,7 @@ export const getBackgroundTrimLimit = (): number => Math.round(getMessageLimit() * 0.6); // --- Backward-compat shims (avoid mass refactor of non-critical callers) --- -export const DEFAULT_MEMORY_LIMITS = { +const DEFAULT_MEMORY_LIMITS = { MAX_SESSIONS: MEMORY_CONSTANTS.MAX_SESSIONS, VIEWPORT_MESSAGES: Math.round(DEFAULT_MESSAGE_LIMIT * 0.6), HISTORICAL_MESSAGES: DEFAULT_MESSAGE_LIMIT, @@ -116,216 +83,4 @@ export const getMemoryLimits = () => { }; }; -export const getActiveSessionWindow = () => getMessageLimit(); - -export const DEFAULT_ACTIVE_SESSION_WINDOW = DEFAULT_MESSAGE_LIMIT; export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS; -export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW; - -/** Synthetic context parts to attach when sending initial message */ -export interface SyntheticContextPart { - text: string; - synthetic: true; -} - -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; - /** Synthetic context parts to include with the initial message */ - syntheticParts?: SyntheticContextPart[]; - targetFolderId?: string; -}; - -// Voice state types -export type VoiceStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type VoiceMode = 'idle' | 'speaking' | 'listening'; - -export interface VoiceState { - status: VoiceStatus; - mode: VoiceMode; -} - -export interface SessionStore { - - sessions: Session[]; - archivedSessions: Session[]; - sessionsByDirectory: Map; - currentSessionId: string | null; - lastLoadedDirectory: string | null; - messages: Map; - sessionMemoryState: Map; - sessionHistoryMeta: Map; - messageStreamStates: Map; - sessionCompactionUntil: Map; - permissions: Map; - questions: Map; - sessionAbortFlags: Map; - attachedFiles: AttachedFile[]; - abortPromptSessionId: string | null; - abortPromptExpiresAt: number | null; - isLoading: boolean; - error: string | null; - streamingMessageIds: Map; - abortControllers: Map; - lastUsedProvider: { providerID: string; modelID: string } | null; - isSyncing: boolean; - - sessionModelSelections: Map; - sessionAgentSelections: Map; - - sessionAgentModelSelections: Map>; - - webUICreatedSessions: Set; - worktreeMetadata: Map; - availableWorktrees: import('@/types/worktree').WorktreeMetadata[]; - availableWorktreesByProject: Map; - - currentAgentContext: Map; - - sessionContextUsage: Map; - - sessionAgentEditModes: Map>; - - // Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle). - // Use as the single source of truth for "assistant working" UI. - // confirmedAt: timestamp when idle was confirmed locally (prevents race with server polling) - sessionStatus?: Map< - string, - { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number } - >; - - // sessionAttentionStates removed — replaced by notification-store - - userSummaryTitles: Map; - - pendingInputText: string | null; - pendingInputMode: 'replace' | 'append' | 'append-inline'; - /** Synthetic context parts to include with the next message sent */ - pendingSyntheticParts: SyntheticContextPart[] | null; - - newSessionDraft: NewSessionDraftState; - - // Voice state - voiceStatus: VoiceStatus; - voiceMode: VoiceMode; - - // Voice actions - setVoiceStatus: (status: VoiceStatus) => void; - setVoiceMode: (mode: VoiceMode) => void; - - getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => EditPermissionMode; - toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => void; - setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void; - loadSessions: () => Promise; - - openNewSessionDraft: (options?: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void; - overrideNewSessionDraftTarget: (options: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; title?: string; initialPrompt?: string }) => void; - setNewSessionDraftTarget: (target: { projectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void; - setPendingDraftWorktreeRequest: (requestId: string | null) => void; - resolvePendingDraftWorktreeTarget: (requestId: string, directory: string | null, options?: { projectId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean }) => void; - setDraftBootstrapPendingDirectory: (directory: string | null) => void; - setDraftPreserveDirectoryOverride: (value: boolean) => void; - closeNewSessionDraft: () => void; - - createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; - createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string; createWorktree?: boolean }) => 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; - loadMessages: (sessionId: string, limit?: number) => Promise; - 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', options?: { sessionId?: string }) => Promise; - abortCurrentOperation: (sessionIdOverride?: string) => Promise; - acknowledgeSessionAbort: (sessionId: string) => void; - armAbortPrompt: (durationMs?: number) => number | null; - clearAbortPrompt: () => void; - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => void; - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string) => void; - completeStreamingMessage: (sessionId: string, messageId: string) => void; - markMessageStreamSettled: (messageId: string) => void; - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void; - updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void; - addPermission: (permission: PermissionRequest) => void; - respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; - dismissPermission: (sessionId: string, requestId: string) => void; - - 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; - - clearError: () => void; - 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; - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => void; - applySessionMetadata: (sessionId: string, metadata: Partial) => void; - setSessionDirectory: (sessionId: string, directory: string | null) => void; - - addAttachedFile: (file: File) => Promise; - addServerFile: (path: string, name: string, content?: string) => Promise; - removeAttachedFile: (id: string) => void; - clearAttachedFiles: () => void; - - updateViewportAnchor: (sessionId: string, anchor: number) => void; - loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; - - 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; - - - isOpenChamberCreatedSession: (sessionId: string) => boolean; - - markSessionAsOpenChamberCreated: (sessionId: string) => void; - - initializeNewOpenChamberSession: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => void; - - setWorktreeMetadata: (sessionId: string, metadata: import('@/types/worktree').WorktreeMetadata | null) => void; - getWorktreeMetadata: (sessionId: string) => import('@/types/worktree').WorktreeMetadata | undefined; - - getContextUsage: (contextLimit: number, outputLimit: number) => SessionContextUsage | null; - - updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void; - - initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void; - - debugSessionMessages: (sessionId: string) => Promise; - - pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void; - updateSession: (session: Session) => void; - removeSessionFromStore: (sessionId: string) => void; - - revertToMessage: (sessionId: string, messageId: string) => Promise; - handleSlashUndo: (sessionId: string) => Promise; - handleSlashRedo: (sessionId: string) => Promise; - forkFromMessage: (sessionId: string, messageId: string) => Promise; - 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; - } diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index 31188324..69cbddc8 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -41,7 +41,7 @@ export interface AgentGroup { sessionCount: number; } -export interface DeleteAgentGroupResult { +interface DeleteAgentGroupResult { failedIds: string[]; failedWorktreePaths: string[]; } @@ -50,7 +50,7 @@ export interface DeleteAgentGroupResult { // parseSessionTitle // --------------------------------------------------------------------------- -export function parseSessionTitle(title: string | undefined): { +function parseSessionTitle(title: string | undefined): { groupSlug: string; provider: string; model: string; diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index a3accc94..517a0567 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -516,39 +516,6 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n } } -export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) { - startConfigUpdate(options?.message || "Reloading OpenCode configuration…"); - - try { - const response = await runtimeFetch('/api/config/reload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - - const payload = await response.json().catch(() => null); - - if (!response.ok) { - const message = payload?.error || 'Failed to reload configuration'; - throw new Error(message); - } - - if (payload?.requiresReload) { - await performFullConfigRefresh({ - message: payload.message, - delayMs: payload.reloadDelayMs, - }); - } else { - await performFullConfigRefresh(options); - } - } catch (error) { - console.error('[reloadOpenCodeConfiguration] Failed:', error); - updateConfigUpdateMessage('Failed to reload configuration. Please try again.'); - await sleep(2000); - finishConfigUpdate(); - throw error; - } -} - let unsubscribeCommandsConfigChanges: (() => void) | null = null; if (!unsubscribeCommandsConfigChanges) { diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index ea94d9c2..6cf8a2c5 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -632,15 +632,6 @@ export const useGitHubPrStatusStore = create()( }, ), ); - -export const usePrStatusForDirectoryBranch = (directory: string | null, branch: string | null) => { - return useGitHubPrStatusStore((state) => { - if (!directory || !branch) return null; - const key = getGitHubPrStatusKey(directory, branch); - return state.entries[key] ?? null; - }); -}; - export type PrVisualSummary = { number: number; visualState: string; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 96aaa0dd..87aebe8c 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -1048,13 +1048,6 @@ export const useIsGitRepo = (directory: string | null) => { }); }; -export const useGitFileCount = (directory: string | null) => { - return useGitStore((state) => { - if (!directory) return 0; - return state.directories.get(directory)?.status?.files?.length ?? 0; - }); -}; - export const useGitBranchLabel = (directory: string | null) => { return useGitStore((state) => { if (!directory) return null; @@ -1083,26 +1076,6 @@ export const useGitAllBranches = () => { }); }; -export const useGitBranchMap = (directories: string[]) => { - const cacheRef = React.useRef>(new Map()); - return useGitStore((state) => { - const prev = cacheRef.current; - let same = prev.size === directories.length; - if (same) { - for (const dir of directories) { - if (prev.get(dir) !== (state.directories.get(dir)?.status?.current ?? null)) { same = false; break; } - } - } - if (same) return prev; - const result = new Map(); - for (const dir of directories) { - result.set(dir, state.directories.get(dir)?.status?.current ?? null); - } - cacheRef.current = result; - return result; - }); -}; - export const useGitRepoStatusMap = (directories: string[]) => { const cacheRef = React.useRef>(new Map()); return useGitStore((state) => { @@ -1146,10 +1119,3 @@ export const useGitLoadingBranches = (directory: string | null) => { return state.directories.get(directory)?.isLoadingBranches ?? false; }); }; - -export const useGitLoadingIdentity = (directory: string | null) => { - return useGitStore((state) => { - if (!directory) return false; - return state.directories.get(directory)?.isLoadingIdentity ?? false; - }); -}; diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts index 8571a6d6..e6c48129 100644 --- a/packages/ui/src/stores/useInlineCommentDraftStore.ts +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -224,5 +224,3 @@ export const useInlineCommentDraftStore = create()( { name: 'inline-comment-draft-store' } ) ); - -export default useInlineCommentDraftStore; diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 216894e5..41a4be33 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -39,21 +39,21 @@ const getConfigDirectory = (): string | null => { // ============== TYPES ============== -export interface McpLocalConfig { +interface McpLocalConfig { type: 'local'; command: string[]; environment?: Record; enabled: boolean; } -export interface McpOAuthConfig { +interface McpOAuthConfig { clientId?: string; clientSecret?: string; scope?: string; redirectUri?: string; } -export interface McpRemoteConfig { +interface McpRemoteConfig { type: 'remote'; url: string; environment?: Record; @@ -64,7 +64,7 @@ export interface McpRemoteConfig { } export type McpServerConfig = (McpLocalConfig | McpRemoteConfig) & { name: string }; -export type McpServerWithScope = McpServerConfig & { scope?: McpScope | null }; +type McpServerWithScope = McpServerConfig & { scope?: McpScope | null }; export interface McpDraft { name: string; @@ -90,7 +90,7 @@ export const envRecordToArray = (env?: Record): Array<{ key: str return Object.entries(env).map(([key, value]) => ({ key, value })); }; -export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record | undefined => { +const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record | undefined => { const filtered = arr.filter((e) => e.key.trim()); if (filtered.length === 0) return undefined; return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value])); diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index 0bdf1138..d542fc8f 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -5,11 +5,11 @@ import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; export type McpStatusMap = Record; -export type McpRuntimeDiagnostic = { +type McpRuntimeDiagnostic = { status: 'failed'; error: string; }; -export type McpRuntimeDiagnosticMap = Record; +type McpRuntimeDiagnosticMap = Record; const EMPTY_STATUS: McpStatusMap = {}; const EMPTY_DIAGNOSTICS: McpRuntimeDiagnosticMap = {}; diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 75cafa8f..2a6aa5e2 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -11,7 +11,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { runtimeFetch } from '@/lib/runtime-fetch'; export type PluginScope = 'user' | 'project'; -export type PluginParsedKind = 'npm' | 'path'; +type PluginParsedKind = 'npm' | 'path'; export interface PluginEntry { id: string; @@ -123,7 +123,7 @@ const getConfigDirectory = (): string | null => { }; const CLIENT_RELOAD_DELAY_MS = 800; -export const PLUGINS_LOAD_CACHE_TTL_MS = 5000; +const PLUGINS_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_PLUGINS_CACHE_KEY = '__default__'; const pluginsLastLoadedAt = new Map(); const pluginsLoadInFlight = new Map>(); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index 09760226..0b87c83f 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -export type SessionDisplayMode = 'default' | 'minimal'; +type SessionDisplayMode = 'default' | 'minimal'; type SessionDisplayStore = { displayMode: SessionDisplayMode; diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index b59ec55c..f2e3dae0 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -40,7 +40,7 @@ export interface SupportingFile { fullPath: string; } -export interface SkillSources { +interface SkillSources { md: { exists: boolean; path: string | null; @@ -123,7 +123,7 @@ export interface SkillDraft { pendingFiles?: PendingFile[]; } -export interface SkillDetail { +interface SkillDetail { name: string; sources: SkillSources; scope?: SkillScope | null; diff --git a/packages/ui/src/stores/useSnippetsStore.ts b/packages/ui/src/stores/useSnippetsStore.ts index 25e9df9f..5c4a4c70 100644 --- a/packages/ui/src/stores/useSnippetsStore.ts +++ b/packages/ui/src/stores/useSnippetsStore.ts @@ -8,7 +8,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; export type SnippetScope = 'global' | 'project'; -export interface SnippetDraft { +interface SnippetDraft { name: string; scope: SnippetScope; content?: string; diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 97ca4766..be708f87 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -13,7 +13,7 @@ import { } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; -export type UpdateState = { +type UpdateState = { checking: boolean; available: boolean; downloading: boolean; diff --git a/packages/ui/src/stores/utils/messageProjectors.ts b/packages/ui/src/stores/utils/messageProjectors.ts deleted file mode 100644 index daeea7e8..00000000 --- a/packages/ui/src/stores/utils/messageProjectors.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Message, Part } from '@opencode-ai/sdk/v2'; - -export const resolveClientRole = (info: Pick & { clientRole?: string | null }): string => { - const role = info.clientRole ?? info.role; - return typeof role === 'string' ? role : ''; -}; - -export const normalizeMessageInfoForProjection = (info: T): T => { - const clientRole = resolveClientRole(info); - const shouldMarkUser = clientRole === 'user'; - - return { - ...info, - clientRole, - ...(shouldMarkUser ? { userMessageMarker: true } : {}), - } as T; -}; - -export interface ChatMessageRecord { - info: Message; - parts: Part[]; -} - -export const normalizeMessageRecordsForProjection = (messages: ChatMessageRecord[]): ChatMessageRecord[] => { - return messages.map((message) => ({ - ...message, - info: normalizeMessageInfoForProjection(message.info), - parts: Array.isArray(message.parts) ? message.parts : [], - })); -}; - -export const filterMessagesByRevertPoint = ( - messages: T[], - revertMessageId: string | null, -): T[] => { - if (!revertMessageId) { - return messages; - } - - const revertIndex = messages.findIndex((message) => message.info.id === revertMessageId); - if (revertIndex < 0) { - return messages; - } - - return messages.slice(0, revertIndex); -}; diff --git a/packages/ui/src/stores/utils/messageUtils.ts b/packages/ui/src/stores/utils/messageUtils.ts deleted file mode 100644 index 50274297..00000000 --- a/packages/ui/src/stores/utils/messageUtils.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { Part } from "@opencode-ai/sdk/v2"; -import { isFinalToolStatus } from "@/lib/toolStatus"; - -const isRecord = (value: unknown): value is Record => { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -}; - -const readTimestamp = (value: unknown): number | undefined => { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -}; - -const mergeTimeRange = (existing: unknown, incoming: unknown): Record | undefined => { - const existingTime = isRecord(existing) ? existing : undefined; - const incomingTime = isRecord(incoming) ? incoming : undefined; - - const startCandidates = [ - readTimestamp(existingTime?.start), - readTimestamp(incomingTime?.start), - ].filter((value): value is number => typeof value === 'number'); - const endCandidates = [ - readTimestamp(existingTime?.end), - readTimestamp(incomingTime?.end), - ].filter((value): value is number => typeof value === 'number'); - - if (startCandidates.length === 0 && endCandidates.length === 0) { - return undefined; - } - - const merged: Record = {}; - if (startCandidates.length > 0) { - merged.start = Math.min(...startCandidates); - } - if (endCandidates.length > 0) { - merged.end = Math.max(...endCandidates); - } - return merged; -}; - -const mergeToolState = (existing: unknown, incoming: unknown): Record | undefined => { - const existingState = isRecord(existing) ? existing : undefined; - const incomingState = isRecord(incoming) ? incoming : undefined; - - if (!existingState && !incomingState) { - return undefined; - } - - // Never downgrade a terminal status. Once a tool reaches completed/error/etc, - // a late-arriving "running" SSE must not overwrite it. - const existingStatus = existingState?.status; - const incomingStatus = incomingState?.status; - const existingIsTerminal = typeof existingStatus === 'string' && isFinalToolStatus(existingStatus); - const incomingIsTerminal = typeof incomingStatus === 'string' && isFinalToolStatus(incomingStatus); - - const merged: Record = { - ...(existingState ?? {}), - ...(incomingState ?? {}), - }; - - // If existing was terminal but incoming is not, keep the terminal status. - if (existingIsTerminal && !incomingIsTerminal && typeof incomingStatus === 'string') { - merged.status = existingStatus; - } - - const mergedTime = mergeTimeRange(existingState?.time, incomingState?.time); - if (mergedTime) { - merged.time = mergedTime; - } - - if (isRecord(existingState?.metadata) || isRecord(incomingState?.metadata)) { - merged.metadata = { - ...(isRecord(existingState?.metadata) ? existingState.metadata : {}), - ...(isRecord(incomingState?.metadata) ? incomingState.metadata : {}), - }; - } - - if (isRecord(existingState?.input) || isRecord(incomingState?.input)) { - merged.input = { - ...(isRecord(existingState?.input) ? existingState.input : {}), - ...(isRecord(incomingState?.input) ? incomingState.input : {}), - }; - } - - return merged; -}; - -const extractTextFromDelta = (delta: unknown): string => { - if (!delta) return ''; - if (typeof delta === 'string') return delta; - if (Array.isArray(delta)) { - return delta.map((item) => extractTextFromDelta(item)).join(''); - } - if (typeof delta === 'object') { - if (typeof (delta as { text?: unknown }).text === 'string') { - return (delta as { text: string }).text; - } - if (Array.isArray((delta as { content?: unknown[] }).content)) { - return (delta as { content: unknown[] }).content.map((item: unknown) => extractTextFromDelta(item)).join(''); - } - } - return ''; -}; - -export const extractTextFromPart = (part: unknown): string => { - if (!part) return ''; - const typedPart = part as { text?: string | unknown[]; content?: string | unknown[]; value?: string | unknown[]; delta?: unknown }; - - const toText = (value: unknown): string => { - if (typeof value === 'string') { - return value; - } - if (Array.isArray(value)) { - return value - .map((item: unknown) => { - if (typeof item === 'string') return item; - if (item && typeof item === 'object') { - const typedItem = item as { text?: unknown; content?: unknown; value?: unknown; delta?: unknown }; - return toText(typedItem.text) || toText(typedItem.content) || toText(typedItem.value) || extractTextFromDelta(typedItem.delta); - } - return ''; - }) - .join(''); - } - return ''; - }; - - const candidates = [ - toText(typedPart.text), - toText(typedPart.content), - toText(typedPart.value), - extractTextFromDelta(typedPart.delta), - ]; - - let best = ''; - for (const candidate of candidates) { - if (candidate.length > best.length) { - best = candidate; - } - } - - return best; -}; - -export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => { - const normalized: { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown } = { - ...(existing as Record | undefined), - ...incoming, - } as { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown }; - const existingType = typeof (existing as { type?: unknown } | undefined)?.type === 'string' - ? (existing as { type: string }).type - : undefined; - normalized.type = normalized.type || existingType || 'text'; - - const isStreamingTextLikePart = normalized.type === 'text' || normalized.type === 'reasoning'; - - if (isStreamingTextLikePart) { - const existingRecord = (existing ?? {}) as { text?: unknown; content?: unknown; value?: unknown }; - const existingText = extractTextFromPart(existing); - const directText = extractTextFromPart(incoming); - const deltaText = extractTextFromDelta((incoming as { delta?: unknown }).delta); - let mergedText = ''; - - const incomingField = - typeof normalized.text === 'string' - ? 'text' - : typeof normalized.content === 'string' - ? 'content' - : typeof normalized.value === 'string' - ? 'value' - : null; - - const targetField = incomingField ?? ( - typeof existingRecord.text === 'string' - ? 'text' - : typeof existingRecord.content === 'string' - ? 'content' - : typeof existingRecord.value === 'string' - ? 'value' - : 'text' - ); - - if (deltaText) { - mergedText = existingText ? `${existingText}${deltaText}` : deltaText; - } else if (directText) { - mergedText = directText; - } else { - mergedText = existingText; - } - - normalized[targetField] = mergedText; - if (targetField !== 'text') { - normalized.text = mergedText; - } - - delete normalized.delta; - } - - const mergedTime = mergeTimeRange( - (existing as { time?: unknown } | undefined)?.time, - (incoming as { time?: unknown } | undefined)?.time, - ); - if (mergedTime) { - normalized.time = mergedTime; - } - - if (normalized.type === 'tool') { - const mergedState = mergeToolState( - (existing as { state?: unknown } | undefined)?.state, - (incoming as { state?: unknown } | undefined)?.state, - ); - if (mergedState) { - normalized.state = mergedState; - } - } - - 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/permissionUtils.ts b/packages/ui/src/stores/utils/permissionUtils.ts index 57fb74d4..1bf20fc4 100644 --- a/packages/ui/src/stores/utils/permissionUtils.ts +++ b/packages/ui/src/stores/utils/permissionUtils.ts @@ -1,20 +1,5 @@ import type { EditPermissionMode } from "../types/sessionTypes"; -const EDIT_PERMISSION_TOOL_NAMES = new Set([ - 'edit', - 'multiedit', - 'str_replace', - 'str_replace_based_edit_tool', - 'write', -]); - -export const isEditPermissionType = (type?: string | null): boolean => { - if (!type) { - return false; - } - return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase()); -}; - type PermissionAction = 'allow' | 'deny' | 'ask'; type PermissionRule = { diff --git a/packages/ui/src/stores/utils/streamDebug.ts b/packages/ui/src/stores/utils/streamDebug.ts index 5ee628d9..aa5f38a2 100644 --- a/packages/ui/src/stores/utils/streamDebug.ts +++ b/packages/ui/src/stores/utils/streamDebug.ts @@ -7,15 +7,6 @@ export const streamDebugEnabled = (): boolean => { } }; -export const sessionStatusDebugEnabled = (): boolean => { - if (typeof window === 'undefined') return false; - try { - return window.localStorage.getItem('openchamber_session_status_debug') === '1'; - } catch { - return false; - } -}; - const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf'; type PerfCounter = { @@ -31,7 +22,7 @@ type StreamPerfState = { lastUpdatedAt: number; }; -export type StreamPerfEntry = { +type StreamPerfEntry = { metric: string; count: number; avg: number; @@ -61,7 +52,7 @@ declare global { } } -export const streamPerfEnabled = (): boolean => { +const streamPerfEnabled = (): boolean => { if (typeof window === 'undefined') return false; try { return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; diff --git a/packages/ui/src/stores/utils/streamingUtils.ts b/packages/ui/src/stores/utils/streamingUtils.ts deleted file mode 100644 index 438fab59..00000000 --- a/packages/ui/src/stores/utils/streamingUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { MessageStreamLifecycle } from "../types/sessionTypes"; - -export type { MessageStreamLifecycle }; - -export const touchStreamingLifecycle = ( - source: Map, - messageId: string -): Map => { - const now = Date.now(); - const existing = source.get(messageId); - - const next = new Map(source); - next.set(messageId, { - phase: 'streaming', - startedAt: existing?.startedAt ?? now, - lastUpdateAt: now, - }); - - return next; -}; - -export const removeLifecycleEntries = ( - source: Map, - ids: Iterable -): Map => { - const idsArray = Array.from(ids); - const shouldClone = idsArray.some((id) => source.has(id)); - - if (!shouldClone) { - return source; - } - - const next = new Map(source); - idsArray.forEach((id) => { - next.delete(id); - }); - - return next; -}; - -const lifecycleCompletionTimers = new Map>(); - -export const clearLifecycleCompletionTimer = (messageId: string) => { - const timer = lifecycleCompletionTimers.get(messageId); - if (timer) { - clearTimeout(timer); - lifecycleCompletionTimers.delete(messageId); - } -}; - -export const clearLifecycleTimersForIds = (ids: Iterable) => { - for (const id of ids) { - clearLifecycleCompletionTimer(id); - } -}; diff --git a/packages/ui/src/sync/binary.ts b/packages/ui/src/sync/binary.ts index 03c585cf..98dfba66 100644 --- a/packages/ui/src/sync/binary.ts +++ b/packages/ui/src/sync/binary.ts @@ -23,24 +23,4 @@ export namespace Binary { 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/content-cache.ts b/packages/ui/src/sync/content-cache.ts index 4e552be8..eaf5b218 100644 --- a/packages/ui/src/sync/content-cache.ts +++ b/packages/ui/src/sync/content-cache.ts @@ -63,11 +63,6 @@ export function evictContentLru(keep: Set | undefined, evict: (path: str } } -export function resetContentLru() { - lru.clear() - total = 0 -} - export function setContentBytes(path: string, bytes: number) { setBytes(path, bytes) } @@ -79,15 +74,3 @@ export function removeContentBytes(path: string) { 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/debug.ts b/packages/ui/src/sync/debug.ts index f4191854..b792b445 100644 --- a/packages/ui/src/sync/debug.ts +++ b/packages/ui/src/sync/debug.ts @@ -14,7 +14,7 @@ const FLAG_KEY = "openchamber:sync:debug" let _enabled: boolean | undefined -export function isSyncDebugEnabled(): boolean { +function isSyncDebugEnabled(): boolean { if (_enabled !== undefined) return _enabled try { _enabled = typeof localStorage !== "undefined" && localStorage.getItem(FLAG_KEY) === "1" @@ -23,12 +23,6 @@ export function isSyncDebugEnabled(): boolean { } return _enabled } - -/** Force-refresh the flag (call after user toggles localStorage). */ -export function refreshSyncDebugFlag(): void { - _enabled = undefined -} - type SyncDebugCategory = "pipeline" | "reducer" | "dispatch" function log(cat: SyncDebugCategory, ...args: unknown[]): void { diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index 701903a4..431b617c 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -18,13 +18,6 @@ import { getRuntimeUrlResolver } from "@/lib/runtime-url" import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from "@/lib/runtime-auth" import { syncDebug } from "./debug" -export type QueuedEvent = { - directory: string - payload: Event -} - -export type FlushHandler = (events: QueuedEvent[]) => void - const FLUSH_FRAME_MS = 33 const BACKPRESSURE_FLUSH_FRAME_MS = 200 const BACKPRESSURE_MODE_MS = 10_000 diff --git a/packages/ui/src/sync/global-sync-store.ts b/packages/ui/src/sync/global-sync-store.ts index 52dea534..3a4aee76 100644 --- a/packages/ui/src/sync/global-sync-store.ts +++ b/packages/ui/src/sync/global-sync-store.ts @@ -16,12 +16,3 @@ export const useGlobalSyncStore = create()((set) => ({ 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 deleted file mode 100644 index a186ae22..00000000 --- a/packages/ui/src/sync/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -// 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, - useSessionMessageCount, - useSessionMessagesResolved, - useSessionParts, - useSessionStatus, - useSessionPermissions, - useSessionQuestions, - useSessions, - useSyncSDK, - useSyncDirectory, - useChildStoreManager, - useSessionMessageRecords, - useEnsureSessionMessages, - useSessionTextMessages, - useUserMessageHistory, - buildSessionMessageRecordsSnapshot, -} 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/notification-store.ts b/packages/ui/src/sync/notification-store.ts index 1501c7b7..9f7d237e 100644 --- a/packages/ui/src/sync/notification-store.ts +++ b/packages/ui/src/sync/notification-store.ts @@ -161,10 +161,3 @@ 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 index ac1b5a48..14a7eb73 100644 --- a/packages/ui/src/sync/optimistic.ts +++ b/packages/ui/src/sync/optimistic.ts @@ -7,27 +7,11 @@ 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[] }[] @@ -86,30 +70,6 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) } } -/** 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. */ diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index 7e5e5035..35808852 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -65,20 +65,6 @@ function writeCache(directory: string, key: CacheKey, value: T | undefined): } } -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 // --------------------------------------------------------------------------- @@ -130,8 +116,3 @@ export function persistProjectMeta(directory: string, meta: ProjectMeta | undefi 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/reconnect-recovery.ts b/packages/ui/src/sync/reconnect-recovery.ts index 68daaa05..cd5e5a5a 100644 --- a/packages/ui/src/sync/reconnect-recovery.ts +++ b/packages/ui/src/sync/reconnect-recovery.ts @@ -9,7 +9,7 @@ type ReconnectMaterializationState = { part?: Record } -export type ViewedSessionMaterializationTarget = { +type ViewedSessionMaterializationTarget = { directory: string sessionId: string } diff --git a/packages/ui/src/sync/session-prefetch-cache.ts b/packages/ui/src/sync/session-prefetch-cache.ts index 920be7ea..e78c0e13 100644 --- a/packages/ui/src/sync/session-prefetch-cache.ts +++ b/packages/ui/src/sync/session-prefetch-cache.ts @@ -69,34 +69,6 @@ export function subscribeSessionPrefetch(directory: string, sessionID: string, c } } -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 @@ -126,16 +98,3 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable { return replaced.replace(/\/+$/, '') || replaced; }; -export function isWithinWorktreeRoot(candidate: string | null, worktreeRoot: string | null): boolean { +function isWithinWorktreeRoot(candidate: string | null, worktreeRoot: string | null): boolean { if (!candidate || !worktreeRoot) return false; const c = normalizePath(candidate); const r = normalizePath(worktreeRoot); diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts index 62bbe9dd..b580c034 100644 --- a/packages/ui/src/sync/streaming.ts +++ b/packages/ui/src/sync/streaming.ts @@ -10,9 +10,9 @@ 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" +type StreamPhase = "streaming" | "cooldown" | "completed" -export type MessageStreamState = { +type MessageStreamState = { phase: StreamPhase startedAt: number lastUpdateAt: number @@ -140,13 +140,3 @@ export function updateStreamingState(state: State) { }) } } - -// 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 deleted file mode 100644 index 35a1ceb8..00000000 --- a/packages/ui/src/sync/submit.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Message, Part } from "@opencode-ai/sdk/v2/client" -import { useCallback } from "react" -import { opencodeClient } from "@/lib/opencode/client" -import { useDirectoryStore, useSyncDirectory } 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 store = useDirectoryStore() - const directory = useSyncDirectory() - 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 opencodeClient.sendCommand({ - id: input.sessionID, - command: input.command?.name ?? "", - arguments: input.command?.arguments ?? "", - agent: input.agent, - providerID: input.model.providerID, - modelID: input.model.modelID, - variant: input.variant, - files: input.images, - messageId: messageID, - directory, - }).then(() => undefined) - } else { - // Regular prompt - await opencodeClient.sendMessage({ - id: input.sessionID, - agent: input.agent, - providerID: input.model.providerID, - modelID: input.model.modelID, - messageId: messageID, - text: input.text, - files: input.images, - variant: input.variant, - directory, - }).then(() => undefined) - } - 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 - } - }, - [directory, store, sync], - ) - - return submit -} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 97c0d99c..8c27fbaa 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -9,7 +9,7 @@ import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" -import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" +import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" import { aggregateLiveSessions, @@ -151,35 +151,6 @@ export function useAllSessionStatuses(): Record { ) } -type LiveSessionStatusCounts = { - running: number -} - -const EMPTY_LIVE_SESSION_STATUS_COUNTS: LiveSessionStatusCounts = { running: 0 } - -const isRunningSessionStatus = (status: SessionStatus | undefined): boolean => ( - status?.type === "busy" || status?.type === "retry" -) - -const areLiveSessionStatusCountsEquivalent = (left: LiveSessionStatusCounts, right: LiveSessionStatusCounts): boolean => ( - left.running === right.running -) - -export function useLiveSessionStatusCounts(): LiveSessionStatusCounts { - return useLiveSyncSelector( - useCallback((states) => { - let running = 0 - for (const state of states) { - for (const status of Object.values(state.session_status ?? {})) { - if (isRunningSessionStatus(status)) running += 1 - } - } - return running === 0 ? EMPTY_LIVE_SESSION_STATUS_COUNTS : { running } - }, []), - areLiveSessionStatusCountsEquivalent, - ) -} - export function useAllLiveSessions(): Session[] { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessions(states), []), @@ -2047,16 +2018,6 @@ export function SyncProvider(props: { // 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). * @@ -2081,18 +2042,7 @@ export function useDirectorySync(selector: (state: State) => T, directory?: s 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 */ +/** Get session messages for a specific session */ export function useSessionMessages(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { @@ -2106,19 +2056,6 @@ export function useSessionMessages(sessionID: string, directory?: string) { return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } -/** - * 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]) -} - /** Check whether the message list for a session has been loaded into sync state. */ export function useSessionMessagesResolved(sessionID: string, directory?: string): boolean { return useDirectorySync( @@ -2253,116 +2190,6 @@ export function useParentSession(sessionID: string | null, directory?: string): ) } -const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => { - const directory = (session as Session & { directory?: string | null }).directory ?? '' - const parentID = (session as Session & { parentID?: string | null }).parentID ?? '' - const projectWorktree = (session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? '' - const shared = session.share?.url ?? '' - return [ - session.id, - session.title ?? '', - session.time?.created ?? 0, - session.time?.archived ? 1 : 0, - directory, - parentID, - projectWorktree, - shared, - stableUpdatedAt, - ].join('|') -} - -/** Get sessions stabilized for sidebar tree rendering */ -export function useSidebarSessions(directory?: string): Session[] { - const store = useDirectoryStore(directory) - const cacheRef = React.useRef<{ - source: Session[] - streamingSignature: string - array: Session[] - signatures: Map - sessionsById: Map - stableUpdatedAtById: Map - streamingById: Map - } | null>(null) - - const getSnapshot = React.useCallback(() => { - const state = store.getState() - const source = state.session - const cached = cacheRef.current - const streamingSignature = source - .map((session) => { - const statusType = state.session_status?.[session.id]?.type - const isStreaming = statusType === 'busy' || statusType === 'retry' - return `${session.id}:${isStreaming ? 1 : 0}` - }) - .join('|') - - if (cached && cached.source === source && cached.streamingSignature === streamingSignature) { - return cached.array - } - - const signatures = new Map() - const sessionsById = new Map() - const stableUpdatedAtById = new Map() - const streamingById = new Map() - let changed = !cached || cached.array.length !== source.length - - const array = source.map((session) => { - const rawUpdatedAt = Number(session.time?.updated ?? session.time?.created ?? 0) - const statusType = state.session_status?.[session.id]?.type - const isStreaming = statusType === 'busy' || statusType === 'retry' - const cachedUpdatedAt = cached?.stableUpdatedAtById.get(session.id) ?? rawUpdatedAt - const wasStreaming = cached?.streamingById.get(session.id) ?? false - const stableUpdatedAt = isStreaming - ? (wasStreaming ? cachedUpdatedAt : Math.max(rawUpdatedAt, cachedUpdatedAt, Date.now())) - : Math.max(rawUpdatedAt, cachedUpdatedAt) - const signature = getSidebarSessionSignature(session, stableUpdatedAt) - signatures.set(session.id, signature) - stableUpdatedAtById.set(session.id, stableUpdatedAt) - streamingById.set(session.id, isStreaming) - - const cachedSession = cached?.sessionsById.get(session.id) - if ( - cachedSession - && cached?.signatures.get(session.id) === signature - ) { - sessionsById.set(session.id, cachedSession) - return cachedSession - } - - changed = true - const nextSession = stableUpdatedAt === rawUpdatedAt - ? session - : { - ...session, - time: { - ...session.time, - updated: stableUpdatedAt, - }, - } - sessionsById.set(session.id, nextSession) - return nextSession - }) - - if (!changed && cached) { - cacheRef.current = { - source, - streamingSignature, - array: cached.array, - signatures, - sessionsById: cached.sessionsById, - stableUpdatedAtById, - streamingById, - } - return cached.array - } - - cacheRef.current = { source, streamingSignature, array, signatures, sessionsById, stableUpdatedAtById, streamingById } - return array - }, [store]) - - return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot) -} - /** Get one session by id for a directory */ export function useSession(sessionID?: string | null, directory?: string) { const { childStores } = useSyncSystem() @@ -2805,41 +2632,6 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string) })() }, [sessionID, store, resolvedDirectory]) } - -/** - * Determines if a session is actively working. - * Checks session_status and only falls back to incomplete assistant messages - * when authoritative status is missing. - * 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 hasAuthoritativeStatus = status !== undefined - const statusWorking = hasAuthoritativeStatus && 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 - } - } - - if (hasAuthoritativeStatus) return statusWorking - return hasPendingAssistant - }, [status, permissions, messages]) -} - const EMPTY_MESSAGES: Message[] = [] const EMPTY_PARTS: Part[] = [] const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 344c992e..4913d3e3 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -10,19 +10,17 @@ import type { ChildStoreManager } from "./child-store" import { getSessionMaterializationStatus } from "./materialization" import type { State } from "./types" -let _sdk: OpencodeClient | null = null let _childStores: ChildStoreManager | null = null let _directory: string = "" let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null const configListeners = new Set<(directory: string, config: Config) => void>() export function setSyncRefs( - sdk: OpencodeClient, + _sdk: OpencodeClient, childStores: ChildStoreManager, directory: string, registerSessionDirectory?: (sessionID: string, directory: string) => void, ) { - _sdk = sdk _childStores = childStores _directory = directory if (registerSessionDirectory) { @@ -37,20 +35,11 @@ export function registerSessionDirectory(sessionID: string, directory: string) { _registerSessionDirectory?.(sessionID, 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 @@ -121,13 +110,3 @@ export function getSyncParts(messageId: string, directory?: string) { 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 index c516f1b8..d95cdac9 100644 --- a/packages/ui/src/sync/types.ts +++ b/packages/ui/src/sync/types.ts @@ -77,7 +77,7 @@ export type GlobalState = { sessionTodo: Record } -export type InitError = { +type InitError = { type: "init" message: string } @@ -105,14 +105,8 @@ export type DisposeCheck = { hasPendingBlockingRequests: 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 = 40 export const INITIAL_STATE: State = { diff --git a/packages/ui/src/sync/voice-store.ts b/packages/ui/src/sync/voice-store.ts index 494abfce..3a594886 100644 --- a/packages/ui/src/sync/voice-store.ts +++ b/packages/ui/src/sync/voice-store.ts @@ -3,21 +3,5 @@ * 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/ui/src/types/index.ts b/packages/ui/src/types/index.ts index f17f556d..6a9f49bb 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -1,50 +1,10 @@ -import type { Session, Message, Part, Provider } from "@opencode-ai/sdk/v2"; - -export type { Session, Message, Part, Provider }; export type { QuotaProviderId, UsageWindow, UsageWindows, - ProviderUsage, ProviderResult } from './quota'; -export interface ChatState { - sessions: Session[]; - currentSessionId: string | null; - messages: Map; - isLoading: boolean; - error: string | null; - streamingMessageIds: Map; -} - -export interface ConfigState { - providers: Provider[]; - currentProviderId: string; - currentModelId: string; - defaultProvider: { [key: string]: string }; - isConnected: boolean; -} - -export interface UIState { - theme: "light" | "dark" | "system"; - isSidebarOpen: boolean; - isSessionSwitcherOpen: boolean; - isMobile: boolean; - isAbortable: boolean; -} - -export interface StreamEvent { - type: string; - properties: Record; -} - -export interface ModelOption { - providerId: string; - modelId: string; - displayName: string; -} - export interface ModelMetadata { id: string; providerId: string; diff --git a/packages/ui/src/types/multirun.ts b/packages/ui/src/types/multirun.ts index 10aee78a..e0fe5d59 100644 --- a/packages/ui/src/types/multirun.ts +++ b/packages/ui/src/types/multirun.ts @@ -1,4 +1,4 @@ -export interface MultiRunModelSelection { +interface MultiRunModelSelection { providerID: string; modelID: string; displayName?: string; diff --git a/packages/ui/src/types/permission.ts b/packages/ui/src/types/permission.ts index 7cf65084..82c7223e 100644 --- a/packages/ui/src/types/permission.ts +++ b/packages/ui/src/types/permission.ts @@ -11,18 +11,4 @@ export interface PermissionRequest { }; } -export type PermissionResponse = 'once' | 'always' | 'reject'; - -export interface PermissionAskedEvent { - type: 'permission.asked'; - properties: PermissionRequest; -} - -export interface PermissionRepliedEvent { - type: 'permission.replied'; - properties: { - sessionID: string; - requestID: string; - reply: PermissionResponse; - }; -} \ No newline at end of file +export type PermissionResponse = 'once' | 'always' | 'reject'; \ No newline at end of file diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts index bf4cd96d..695ef062 100644 --- a/packages/ui/src/types/question.ts +++ b/packages/ui/src/types/question.ts @@ -1,5 +1,3 @@ -export type QuestionAnswer = string[]; - export interface QuestionOption { label: string; description: string; @@ -21,25 +19,3 @@ export interface QuestionRequest { callID: string; }; } - -export interface QuestionAskedEvent { - type: 'question.asked'; - properties: QuestionRequest; -} - -export interface QuestionRepliedEvent { - type: 'question.replied'; - properties: { - sessionID: string; - requestID: string; - answers: QuestionAnswer[]; - }; -} - -export interface QuestionRejectedEvent { - type: 'question.rejected'; - properties: { - sessionID: string; - requestID: string; - }; -} diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 94778ea4..4fa7e761 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -31,7 +31,7 @@ export interface UsageWindows { windows: Record; } -export interface ProviderUsage extends UsageWindows { +interface ProviderUsage extends UsageWindows { models?: Record; } diff --git a/packages/ui/src/types/theme.ts b/packages/ui/src/types/theme.ts index 6f5406e3..43ca26f5 100644 --- a/packages/ui/src/types/theme.ts +++ b/packages/ui/src/types/theme.ts @@ -1,6 +1,6 @@ -export interface ThemeMetadata { +interface ThemeMetadata { id: string; name: string; description: string; @@ -12,7 +12,7 @@ export interface ThemeMetadata { export type ThemeMode = 'system' | 'light' | 'dark'; -export interface ThemeColor { +interface ThemeColor { base: string; hover?: string; active?: string; @@ -21,7 +21,7 @@ export interface ThemeColor { emphasis?: string; } -export interface SurfaceColors { +interface SurfaceColors { background: string; foreground: string; muted: string; @@ -32,7 +32,7 @@ export interface SurfaceColors { subtle: string; } -export interface InteractiveColors { +interface InteractiveColors { border: string; borderHover: string; borderFocus: string; @@ -45,7 +45,7 @@ export interface InteractiveColors { active: string; } -export interface StatusColors { +interface StatusColors { error: string; errorForeground: string; errorBackground: string; @@ -67,7 +67,7 @@ export interface StatusColors { infoBorder: string; } -export interface PullRequestColors { +interface PullRequestColors { open: string; draft: string; blocked: string; @@ -75,7 +75,7 @@ export interface PullRequestColors { closed: string; } -export interface SyntaxBaseColors { +interface SyntaxBaseColors { background: string; foreground: string; comment: string; @@ -88,14 +88,14 @@ export interface SyntaxBaseColors { operator: string; } -export interface SyntaxColors { +interface SyntaxColors { base: SyntaxBaseColors; tokens?: Partial>; languages?: Record>; highlights?: Record; } -export interface ButtonVariant { +interface ButtonVariant { bg?: string; fg?: string; border?: string; @@ -104,90 +104,6 @@ export interface ButtonVariant { disabled?: string; } -export interface TypographyStyle { - fontSize: string; - lineHeight: string; - letterSpacing?: string; - fontWeight?: string | number; -} - -export interface TypographyScale { - xs: TypographyStyle; - sm: TypographyStyle; - base: TypographyStyle; - lg: TypographyStyle; - xl: TypographyStyle; - '2xl': TypographyStyle; - '3xl': TypographyStyle; - '4xl': TypographyStyle; - '5xl': TypographyStyle; -} - -export interface HeadingStyles { - h1: TypographyStyle; - h2: TypographyStyle; - h3: TypographyStyle; - h4: TypographyStyle; - h5: TypographyStyle; - h6: TypographyStyle; -} - -export interface UITypography { - button: TypographyStyle; - buttonSmall: TypographyStyle; - buttonLarge: TypographyStyle; - label: TypographyStyle; - caption: TypographyStyle; - badge: TypographyStyle; - tooltip: TypographyStyle; - input: TypographyStyle; - helperText: TypographyStyle; -} - -export interface CodeTypography { - inline: TypographyStyle; - block: TypographyStyle; - lineNumbers: TypographyStyle; -} - -export interface MarkdownTypography { - h1: TypographyStyle; - h2: TypographyStyle; - h3: TypographyStyle; - h4: TypographyStyle; - h5: TypographyStyle; - h6: TypographyStyle; - body: TypographyStyle; - bodySmall: TypographyStyle; - bodyLarge: TypographyStyle; - blockquote: TypographyStyle; - list: TypographyStyle; - link: TypographyStyle; - code: TypographyStyle; - codeBlock: TypographyStyle; -} - -export interface SemanticTypography { - - markdown?: string; - code?: string; - uiHeader?: string; - uiLabel?: string; - meta?: string; - micro?: string; -} - -export interface Typography { - - scale?: TypographyScale; - heading?: HeadingStyles; - ui?: UITypography; - code?: CodeTypography; - markdown?: MarkdownTypography; - - semantic?: SemanticTypography; -} - export interface Theme { metadata: ThemeMetadata; @@ -262,30 +178,3 @@ export interface Theme { }; }; } - -export interface ValidationIssue { - path: string; - message: string; - severity: 'error' | 'warning'; -} - -export interface ValidationResult { - isValid: boolean; - errors: ValidationIssue[]; - warnings: ValidationIssue[]; -} - -export interface AccessibilityIssue { - foreground: string; - background: string; - ratio: number; - requirement: number; - pass: boolean; - context: string; -} - -export interface AccessibilityReport { - wcagAA: boolean; - wcagAAA: boolean; - issues: AccessibilityIssue[]; -} diff --git a/packages/ui/src/types/tool.ts b/packages/ui/src/types/tool.ts deleted file mode 100644 index d6c012cc..00000000 --- a/packages/ui/src/types/tool.ts +++ /dev/null @@ -1,53 +0,0 @@ - -export interface ToolState { - status: 'pending' | 'running' | 'completed' | 'error'; -} - -export interface ToolStatePending extends ToolState { - status: 'pending'; -} - -export interface ToolStateRunning extends ToolState { - status: 'running'; - input?: Record; - title?: string; - metadata?: Record; - time: { - start: number; - }; -} - -export interface ToolStateCompleted extends ToolState { - status: 'completed'; - input: Record; - output: string; - title: string; - metadata: Record; - time: { - start: number; - end: number; - }; -} - -export interface ToolStateError extends ToolState { - status: 'error'; - input: Record; - error: string; - metadata?: Record; - time: { - start: number; - end: number; - }; -} - -export type ToolStateUnion = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; - -export interface ToolPart { - id: string; - sessionID: string; - messageID: string; - type: 'tool'; - callID: string; - tool: string; - state: ToolStateUnion; -} \ No newline at end of file diff --git a/packages/ui/src/types/worktree.ts b/packages/ui/src/types/worktree.ts index 08f62d21..a748debc 100644 --- a/packages/ui/src/types/worktree.ts +++ b/packages/ui/src/types/worktree.ts @@ -48,5 +48,3 @@ export interface WorktreeMetadata { /** How this worktree was attached to a session. */ worktreeSource?: 'existing' | 'created-for-session'; } - -export type WorktreeMap = Map; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index b1bb6e4d..22059cbb 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -120,7 +120,7 @@ async function buildGitEnv(): Promise { /** * Initialize the git extension API */ -export async function initGitExtension(): Promise { +async function initGitExtension(): Promise { if (gitApi && gitExtensionEnabled) { return gitApi; } @@ -163,7 +163,7 @@ export async function initGitExtension(): Promise { /** * Get the git API, initializing if necessary */ -export async function getGitApi(): Promise { +async function getGitApi(): Promise { if (gitApi && gitExtensionEnabled) { return gitApi; } @@ -173,7 +173,7 @@ export async function getGitApi(): Promise { /** * Get repository for a given directory */ -export async function getRepository(directory: string): Promise { +async function getRepository(directory: string): Promise { const api = await getGitApi(); if (!api) return null; @@ -333,20 +333,20 @@ export async function isLinkedWorktree(directory: string): Promise { // ============== Status Operations ============== -export interface GitStatusFile { +interface GitStatusFile { path: string; index: string; working_dir: string; } -export interface GitMergeInProgress { +interface GitMergeInProgress { /** Short SHA of MERGE_HEAD */ head: string; /** First line of MERGE_MSG */ message: string; } -export interface GitRebaseInProgress { +interface GitRebaseInProgress { /** Branch name being rebased */ headName: string; /** Short SHA of the onto commit */ @@ -591,7 +591,7 @@ async function getGitStatusRaw(directory: string): Promise { // ============== Branch Operations ============== -export interface GitBranchDetails { +interface GitBranchDetails { current: boolean; name: string; commit: string; @@ -719,43 +719,6 @@ export async function checkoutBranch(directory: string, branch: string): Promise return { success: result.exitCode === 0, branch }; } -/** - * Detach HEAD at current commit - * This allows the current branch to be used in a worktree - */ -export async function detachHead(directory: string): Promise<{ success: boolean; commit: string }> { - // Get current HEAD commit - const headResult = await execGit(['rev-parse', 'HEAD'], directory); - if (headResult.exitCode !== 0) { - return { success: false, commit: '' }; - } - - const commit = headResult.stdout.trim(); - - // Checkout the commit directly to detach HEAD - const result = await execGit(['checkout', '--detach', 'HEAD'], directory); - return { success: result.exitCode === 0, commit }; -} - -/** - * Get the current HEAD branch name (null if detached) - */ -export async function getCurrentBranch(directory: string): Promise { - const repo = await getRepository(directory); - - if (repo) { - const head = repo.state.HEAD; - return head?.name || null; - } - - // Fallback to raw git - const result = await execGit(['symbolic-ref', '--short', 'HEAD'], directory); - if (result.exitCode === 0) { - return result.stdout.trim(); - } - return null; // Detached HEAD -} - /** * Create a new branch */ @@ -825,7 +788,7 @@ type WorktreeListEntry = { branch?: string; }; -export interface GitWorktreeValidationError { +interface GitWorktreeValidationError { code: string; message: string; } @@ -2071,43 +2034,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree return true; } -/** - * Get branches that are available for worktree checkout - * (branches not already checked out in any worktree) - */ -export async function getAvailableBranchesForWorktree(directory: string): Promise { - const [branches, worktrees] = await Promise.all([ - getGitBranches(directory), - listGitWorktrees(directory), - ]); - - // Get set of branches already checked out in worktrees - const checkedOutBranches = new Set(); - for (const wt of worktrees) { - if (wt.branch) { - checkedOutBranches.add(wt.branch.replace(/^refs\/heads\//, '')); - } - } - - // Filter out branches that are already checked out - const availableBranches: GitBranchDetails[] = []; - for (const name of branches.all) { - // Skip remote branches for worktree creation - if (name.startsWith('remotes/')) { - continue; - } - - if (!checkedOutBranches.has(name)) { - const details = branches.branches[name]; - if (details) { - availableBranches.push(details); - } - } - } - - return availableBranches; -} - // ============== Diff Operations ============== /** @@ -2280,10 +2206,6 @@ export async function revertGitFile( } } -export async function stageGitFile(directory: string, filePath: string): Promise { - await stageGitFiles(directory, [filePath]); -} - export async function stageGitFiles(directory: string, filePaths: string[]): Promise { const paths = filePaths.map((path) => path.trim()).filter(Boolean); @@ -2319,10 +2241,6 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro } } -export async function unstageGitFile(directory: string, filePath: string): Promise { - await unstageGitFiles(directory, [filePath]); -} - export async function unstageGitFiles(directory: string, filePaths: string[]): Promise { const paths = filePaths.map((path) => path.trim()).filter(Boolean); @@ -3647,38 +3565,6 @@ export async function resetToCommit( return { success: true }; } -// ============== Stash Operations ============== - -/** - * Stash changes - */ -export async function stash( - directory: string, - options?: { message?: string; includeUntracked?: boolean } -): Promise<{ success: boolean }> { - const args = ['stash', 'push']; - - // Include untracked files by default - if (options?.includeUntracked !== false) { - args.push('--include-untracked'); - } - - if (options?.message) { - args.push('-m', options.message); - } - - const result = await execGit(args, directory); - return { success: result.exitCode === 0 }; -} - -/** - * Pop the most recent stash - */ -export async function stashPop(directory: string): Promise<{ success: boolean }> { - const result = await execGit(['stash', 'pop'], directory); - return { success: result.exitCode === 0 }; -} - // ============== Worktree Validation & Canonicalization ============== /** diff --git a/packages/vscode/src/githubAuth.ts b/packages/vscode/src/githubAuth.ts deleted file mode 100644 index 4d8400ad..00000000 --- a/packages/vscode/src/githubAuth.ts +++ /dev/null @@ -1,271 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs/promises'; - -const DEVICE_CODE_URL = 'https://github.com/login/device/code'; -const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'; -const API_USER_URL = 'https://api.github.com/user'; -const API_EMAILS_URL = 'https://api.github.com/user/emails'; -const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; - -export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizomPOC3eFYo56r'; -export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email'; - -type StoredAuth = { - accessToken: string; - scope?: string; - tokenType?: string; - createdAt?: number; - user?: { login: string; id?: number; avatarUrl?: string }; - accountId?: string; - current?: boolean; -}; - -type JsonRecord = Record; - -type DeviceCodeResponse = { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete?: string; - expires_in: number; - interval: number; -}; - -type TokenResponse = { - access_token?: string; - scope?: string; - token_type?: string; - error?: string; - error_description?: string; -}; - -const authFilePath = (context: vscode.ExtensionContext) => - path.join(context.globalStorageUri.fsPath, 'github-auth.json'); - -const resolveAccountId = (auth: StoredAuth): string => { - if (typeof auth.accountId === 'string' && auth.accountId.trim()) { - return auth.accountId.trim(); - } - if (auth.user?.login) { - return auth.user.login.trim(); - } - if (typeof auth.user?.id === 'number') { - return String(auth.user.id); - } - if (auth.accessToken) { - return `token:${auth.accessToken.slice(0, 8)}`; - } - return ''; -}; - -const normalizeAuthList = (list: StoredAuth[]): { list: StoredAuth[]; changed: boolean } => { - let changed = false; - let currentFound = false; - const normalized = list - .map((entry) => ({ - ...entry, - accountId: resolveAccountId(entry), - current: Boolean(entry.current), - })) - .filter((entry) => Boolean(entry.accessToken)); - - normalized.forEach((entry) => { - if (entry.current && !currentFound) { - currentFound = true; - } else if (entry.current && currentFound) { - entry.current = false; - changed = true; - } - }); - - if (!currentFound && normalized.length > 0) { - normalized[0].current = true; - changed = true; - } - - return { list: normalized, changed }; -}; - -export const readGitHubAuthList = async (context: vscode.ExtensionContext): Promise => { - try { - const raw = await fs.readFile(authFilePath(context), 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed) return []; - const list = Array.isArray(parsed) ? parsed : [parsed]; - const { list: normalized, changed } = normalizeAuthList(list as StoredAuth[]); - if (changed) { - await fs.writeFile(authFilePath(context), JSON.stringify(normalized, null, 2), 'utf8'); - } - return normalized; - } catch { - return []; - } -}; - -export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise => { - const list = await readGitHubAuthList(context); - if (!list.length) return null; - return list.find((entry) => entry.current) ?? list[0] ?? null; -}; - -export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise => { - const list = await readGitHubAuthList(context); - const next = { - ...auth, - accountId: resolveAccountId(auth), - current: true, - }; - const index = list.findIndex((entry) => entry.accountId === next.accountId); - if (index >= 0) { - list[index] = next; - } else { - list.push(next); - } - list.forEach((entry) => { - entry.current = entry.accountId === next.accountId; - }); - - await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true }); - await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8'); - try { - // best-effort perms on unix - await fs.chmod(authFilePath(context), 0o600); - } catch { - // ignore - } -}; - -export const activateGitHubAuth = async (context: vscode.ExtensionContext, accountId: string): Promise => { - const list = await readGitHubAuthList(context); - if (!list.length) return false; - const id = accountId.trim(); - if (!id) return false; - let found = false; - list.forEach((entry) => { - if (entry.accountId === id) { - entry.current = true; - found = true; - } else { - entry.current = false; - } - }); - if (!found) return false; - await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8'); - return true; -}; - -export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise => { - try { - const list = await readGitHubAuthList(context); - if (!list.length) return true; - const remaining = list.filter((entry) => !entry.current); - if (!remaining.length) { - await fs.rm(authFilePath(context)); - return true; - } - remaining[0].current = true; - await fs.writeFile(authFilePath(context), JSON.stringify(remaining, null, 2), 'utf8'); - return true; - } catch (err: unknown) { - if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true; - return false; - } -}; - -const postForm = async (url: string, params: Record): Promise => { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - 'User-Agent': 'OpenChamber', - }, - body: new URLSearchParams(params).toString(), - }); - const payload = (await response.json().catch(() => null)) as T | null; - if (!response.ok) { - const errorDescription = typeof payload?.error_description === 'string' ? payload.error_description : ''; - const error = typeof payload?.error === 'string' ? payload.error : ''; - throw new Error(errorDescription || error || response.statusText); - } - return payload as T; -}; - -export const startDeviceFlow = async (clientId: string, scope: string) => { - const payload = await postForm(DEVICE_CODE_URL, { client_id: clientId, scope }); - return { - 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, - }; -}; - -export const exchangeDeviceCode = async (clientId: string, deviceCode: string) => { - const payload = await postForm(ACCESS_TOKEN_URL, { - client_id: clientId, - device_code: deviceCode, - grant_type: DEVICE_GRANT_TYPE, - }); - return payload; -}; - -export const fetchMe = async (accessToken: string) => { - const response = await fetch(API_USER_URL, { - method: 'GET', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - }, - }); - if (response.status === 401) { - const error = new Error('unauthorized'); - (error as unknown as { status?: number }).status = 401; - throw error; - } - const payload = (await response.json().catch(() => null)) as JsonRecord | null; - if (!response.ok || !payload) { - throw new Error(`GitHub /user failed: ${response.statusText}`); - } - - const name = typeof payload.name === 'string' ? payload.name : undefined; - let email = typeof payload.email === 'string' ? payload.email : undefined; - if (!email) { - try { - const emailsResponse = await fetch(API_EMAILS_URL, { - method: 'GET', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - }, - }); - if (emailsResponse.status === 401) { - const error = new Error('unauthorized'); - (error as unknown as { status?: number }).status = 401; - throw error; - } - const list = (await emailsResponse.json().catch(() => null)) as Array> | null; - if (emailsResponse.ok && Array.isArray(list)) { - const primaryVerified = list.find((e) => Boolean(e?.primary) && Boolean(e?.verified) && typeof e?.email === 'string'); - const anyVerified = list.find((e) => Boolean(e?.verified) && typeof e?.email === 'string'); - email = (primaryVerified?.email as string | undefined) || (anyVerified?.email as string | undefined); - } - } catch { - // ignore - } - } - - return { - login: String(payload.login || ''), - id: typeof payload.id === 'number' ? payload.id : undefined, - avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : undefined, - name, - email, - }; -}; diff --git a/packages/vscode/src/githubIssues.ts b/packages/vscode/src/githubIssues.ts deleted file mode 100644 index 827cbf20..00000000 --- a/packages/vscode/src/githubIssues.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { resolveRepoFromDirectory } from './githubPr'; - -const API_BASE = 'https://api.github.com'; - -type JsonRecord = Record; - -type GitHubRepoRef = { owner: string; repo: string; url: string }; - -type GitHubIssuesListResult = { - connected: boolean; - repo?: GitHubRepoRef | null; - issues?: Array<{ - number: number; - title: string; - url: string; - state: 'open' | 'closed'; - author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null; - labels?: Array<{ name: string; color?: string }>; - }>; - page?: number; - hasMore?: boolean; -}; - -type GitHubIssueGetResult = { - connected: boolean; - repo?: GitHubRepoRef | null; - issue?: { - number: number; - title: string; - url: string; - state: 'open' | 'closed'; - author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null; - labels?: Array<{ name: string; color?: string }>; - body?: string; - assignees?: Array<{ login: string; id?: number; avatarUrl?: string; name?: string; email?: string }>; - createdAt?: string; - updatedAt?: string; - } | null; -}; - -type GitHubIssueCommentsResult = { - connected: boolean; - repo?: GitHubRepoRef | null; - comments?: Array<{ - id: number; - url: string; - body: string; - author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null; - createdAt?: string; - updatedAt?: string; - }>; -}; - -const githubFetch = async ( - url: string, - accessToken: string, - init?: RequestInit, -): Promise => { - return fetch(url, { - ...init, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - ...(init?.headers || {}), - }, - }); -}; - -const jsonOrNull = async (response: Response): Promise => { - return (await response.json().catch(() => null)) as T | null; -}; - -const readString = (value: unknown): string => (typeof value === 'string' ? value : ''); - -const mapUser = (raw: unknown) => { - const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null; - const login = readString(rec?.login); - if (!login) return null; - return { - login, - id: typeof rec?.id === 'number' ? rec.id : undefined, - avatarUrl: readString(rec?.avatar_url) || undefined, - name: undefined, - email: undefined, - }; -}; - -const mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => { - const list = Array.isArray(raw) ? raw : []; - return list - .map((item) => { - const rec = item && typeof item === 'object' ? (item as JsonRecord) : null; - const name = readString(rec?.name); - if (!name) return null; - return { - name, - color: readString(rec?.color) || undefined, - }; - }) - .filter(Boolean) as Array<{ name: string; color?: string }>; -}; - -export const listIssues = async ( - accessToken: string, - directory: string, - page: number = 1, - searchQuery?: string, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, issues: [] }; - } - - if (searchQuery) { - const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:issue state:open`; - const url = new URL(`${API_BASE}/search/issues`); - url.searchParams.set('q', q); - url.searchParams.set('per_page', '50'); - url.searchParams.set('page', String(page)); - - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) { - return { connected: false }; - } - - const json = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(resp); - if (!resp.ok || !json) { - throw new Error('Failed to search issues'); - } - - const totalCount = typeof json.total_count === 'number' ? json.total_count : 0; - const items = Array.isArray(json.items) ? json.items : []; - const issues = items - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - if (!rec || rec.pull_request) return null; - const number = typeof rec.number === 'number' ? rec.number : 0; - if (!number) return null; - const state = readString(rec.state) === 'closed' ? 'closed' : 'open'; - return { - number, - title: readString(rec.title) || '', - url: readString(rec.html_url) || '', - state, - author: mapUser(rec.user), - labels: mapLabels(rec.labels), - }; - }) - .filter(Boolean) as GitHubIssuesListResult['issues']; - - const fetchedCount = (page - 1) * 50 + items.length; - const hasMore = fetchedCount < totalCount; - return { connected: true, repo, issues: issues || [], page, hasMore }; - } - - const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`); - url.searchParams.set('state', 'open'); - url.searchParams.set('per_page', '50'); - url.searchParams.set('page', String(page)); - - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) { - return { connected: false }; - } - - const link = resp.headers.get('link') || ''; - const hasMore = /rel="next"/.test(link); - - const json = await jsonOrNull(resp); - if (!resp.ok || !Array.isArray(json)) { - throw new Error('Failed to load issues'); - } - - const issues = json - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - if (!rec || rec.pull_request) return null; - const number = typeof rec.number === 'number' ? rec.number : 0; - if (!number) return null; - const state = readString(rec.state) === 'closed' ? 'closed' : 'open'; - return { - number, - title: readString(rec.title) || '', - url: readString(rec.html_url) || '', - state, - author: mapUser(rec.user), - labels: mapLabels(rec.labels), - }; - }) - .filter(Boolean) as GitHubIssuesListResult['issues']; - - return { connected: true, repo, issues: issues || [], page, hasMore }; -}; - -export const getIssue = async ( - accessToken: string, - directory: string, - number: number, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, issue: null }; - } - - const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}`, accessToken); - if (resp.status === 401) { - return { connected: false }; - } - const json = await jsonOrNull(resp); - if (!resp.ok || !json) { - throw new Error('Failed to load issue'); - } - if (json.pull_request) { - throw new Error('Not a GitHub issue'); - } - - const state = readString(json.state) === 'closed' ? 'closed' : 'open'; - const assigneesRaw = Array.isArray(json.assignees) ? json.assignees : []; - const assignees = assigneesRaw.map(mapUser).filter(Boolean) as Array>>; - - return { - connected: true, - repo, - issue: { - number: typeof json.number === 'number' ? json.number : number, - title: readString(json.title) || '', - url: readString(json.html_url) || '', - state, - author: mapUser(json.user), - labels: mapLabels(json.labels), - body: readString(json.body) || '', - assignees, - createdAt: readString(json.created_at) || undefined, - updatedAt: readString(json.updated_at) || undefined, - }, - }; -}; - -export const listIssueComments = async ( - accessToken: string, - directory: string, - number: number, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, comments: [] }; - } - - const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments`); - url.searchParams.set('per_page', '100'); - - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) { - return { connected: false }; - } - const json = await jsonOrNull(resp); - if (!resp.ok || !Array.isArray(json)) { - throw new Error('Failed to load issue comments'); - } - - const comments = json - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - if (!rec) return null; - const id = typeof rec.id === 'number' ? rec.id : 0; - if (!id) return null; - return { - id, - url: readString(rec.html_url) || '', - body: readString(rec.body) || '', - author: mapUser(rec.user), - createdAt: readString(rec.created_at) || undefined, - updatedAt: readString(rec.updated_at) || undefined, - }; - }) - .filter(Boolean) as GitHubIssueCommentsResult['comments']; - - return { connected: true, repo, comments: comments || [] }; -}; diff --git a/packages/vscode/src/githubPr.ts b/packages/vscode/src/githubPr.ts deleted file mode 100644 index f04d37d2..00000000 --- a/packages/vscode/src/githubPr.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -const API_BASE = 'https://api.github.com'; - -type JsonRecord = Record; - -type GitHubRepoRef = { owner: string; repo: string; url: string }; - -type GitHubChecksSummary = { - state: 'success' | 'failure' | 'pending' | 'unknown'; - total: number; - success: number; - failure: number; - pending: number; -}; - -type GitHubPullRequest = { - number: number; - title: string; - body?: string; - url: string; - state: 'open' | 'closed' | 'merged'; - draft: boolean; - base: string; - head: string; - headSha?: string; - mergeable?: boolean | null; - mergeableState?: string | null; -}; - -type GitHubPullRequestStatus = { - connected: boolean; - repo?: GitHubRepoRef | null; - branch?: string; - pr?: GitHubPullRequest | null; - checks?: GitHubChecksSummary | null; - canMerge?: boolean; -}; - -type GitHubPullRequestCreateInput = { - directory: string; - title: string; - head: string; - base: string; - body?: string; - draft?: boolean; -}; - -type GitHubPullRequestUpdateInput = { - directory: string; - number: number; - title: string; - body?: string; -}; - -type GitHubPullRequestMergeInput = { - directory: string; - number: number; - method: 'merge' | 'squash' | 'rebase'; -}; - -type GitHubPullRequestMergeResult = { merged: boolean; message?: string }; - -const parseGitHubRemoteUrl = (raw: string): GitHubRepoRef | null => { - const value = raw.trim(); - if (!value) return null; - - if (value.startsWith('git@github.com:')) { - const rest = value.slice('git@github.com:'.length); - const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest; - const [owner, repo] = cleaned.split('/'); - if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; - } - - if (value.startsWith('ssh://git@github.com/')) { - const rest = value.slice('ssh://git@github.com/'.length); - const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest; - const [owner, repo] = cleaned.split('/'); - if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; - } - - try { - const url = new URL(value); - if (url.hostname !== 'github.com') return null; - const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path; - const [owner, repo] = cleaned.split('/'); - if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; - } catch { - return null; - } -}; - -const getOriginRemoteUrl = async (directory: string): Promise => { - try { - const { stdout } = await execFileAsync('git', ['-C', directory, 'remote', 'get-url', 'origin']); - const url = String(stdout || '').trim(); - return url || null; - } catch { - return null; - } -}; - -export const resolveRepoFromDirectory = async (directory: string): Promise => { - const remote = await getOriginRemoteUrl(directory); - if (!remote) return null; - return parseGitHubRemoteUrl(remote); -}; - -const githubFetch = async ( - url: string, - accessToken: string, - init?: RequestInit, -): Promise => { - return fetch(url, { - ...init, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - ...(init?.headers || {}), - }, - }); -}; - -const jsonOrNull = async (response: Response): Promise => { - return (await response.json().catch(() => null)) as T | null; -}; - -const readString = (value: unknown): string => (typeof value === 'string' ? value : ''); - -export const getPullRequestStatus = async ( - accessToken: string, - userLogin: string | null, - directory: string, - branch: string, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }; - } - - const listNumberByHead = async (state: 'open' | 'closed'): Promise => { - const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - url.searchParams.set('state', state); - url.searchParams.set('head', `${repo.owner}:${branch}`); - url.searchParams.set('per_page', '10'); - - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) { - return null; - } - const list = await jsonOrNull>(resp); - return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null; - }; - - const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise => { - const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - url.searchParams.set('state', state); - url.searchParams.set('per_page', '100'); - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) { - return null; - } - const list = await jsonOrNull>(resp); - if (!resp.ok || !Array.isArray(list)) return null; - - const match = list.find((prItem) => { - const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null; - return readString(head?.ref) === branch; - }); - return match && typeof match.number === 'number' ? match.number : null; - }; - - // PR status by branch: - // - Prefer open PRs. - // - If none, surface closed/merged PRs. - // - Fork PR support: head owner differs -> head filter yields empty; fall back to matching head.ref. - let number = await listNumberByHead('open'); - if (!number) number = await listNumberByHead('closed'); - if (!number) number = await listNumberByHeadRef('open'); - if (!number) number = await listNumberByHeadRef('closed'); - - // Detect auth revocation (best-effort) - if (number === null) { - const probeUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - probeUrl.searchParams.set('state', 'open'); - probeUrl.searchParams.set('per_page', '1'); - const probeResp = await githubFetch(probeUrl.toString(), accessToken); - if (probeResp.status === 401) { - return { connected: false }; - } - } - - if (!number) { - return { connected: true, repo, branch, pr: null, checks: null, canMerge: false }; - } - const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken); - if (prResp.status === 401) { - return { connected: false }; - } - const prJson = await jsonOrNull(prResp); - if (!prResp.ok || !prJson) { - throw new Error('Failed to load PR'); - } - - const merged = Boolean(prJson.merged || prJson.merged_at); - const prState = readString(prJson.state); - const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open'); - const pr: GitHubPullRequest = { - number: typeof prJson.number === 'number' ? prJson.number : 0, - title: readString(prJson.title) || '', - body: readString(prJson.body) || '', - url: readString(prJson.html_url) || '', - state, - draft: Boolean(prJson.draft), - base: readString((prJson.base as JsonRecord | undefined)?.ref) || '', - head: readString((prJson.head as JsonRecord | undefined)?.ref) || '', - headSha: readString((prJson.head as JsonRecord | undefined)?.sha) || undefined, - mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null, - mergeableState: readString(prJson.mergeable_state) || undefined, - }; - - let checks: GitHubChecksSummary | null = null; - if (pr.headSha) { - // Prefer check-runs (Actions) - const runsResp = await githubFetch( - `${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/check-runs`, - accessToken, - ); - const runsJson = await jsonOrNull(runsResp); - const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs) - ? ((runsJson as JsonRecord).check_runs as unknown[]) - : []; - - if (runsResp.ok && runs.length > 0) { - const counts = { success: 0, failure: 0, pending: 0 }; - runs.forEach((r) => { - const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null; - const status = readString(rec?.status); - const conclusion = readString(rec?.conclusion); - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - return; - } - if (!conclusion) { - counts.pending += 1; - return; - } - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { - counts.success += 1; - } else { - counts.failure += 1; - } - }); - const total = counts.success + counts.failure + counts.pending; - const state2 = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state: state2, total, ...counts }; - } - - // Fallback: classic statuses - if (!checks) { - const statusResp = await githubFetch( - `${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`, - accessToken, - ); - const statusJson = await jsonOrNull(statusResp); - if (statusResp.ok && statusJson) { - const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - const st = readString((s as JsonRecord | null)?.state); - if (st === 'success') counts.success += 1; - else if (st === 'failure' || st === 'error') counts.failure += 1; - else if (st === 'pending') counts.pending += 1; - }); - const total = counts.success + counts.failure + counts.pending; - const state2 = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state: state2, total, ...counts }; - } - } - } - - let canMerge = false; - if (userLogin) { - const permResp = await githubFetch( - `${API_BASE}/repos/${repo.owner}/${repo.repo}/collaborators/${encodeURIComponent(userLogin)}/permission`, - accessToken, - ); - const permJson = await jsonOrNull<{ permission?: string }>(permResp); - const perm = typeof permJson?.permission === 'string' ? permJson.permission : ''; - canMerge = perm === 'admin' || perm === 'maintain' || perm === 'write'; - } - - return { connected: true, repo, branch, pr, checks, canMerge }; -}; - -export const createPullRequest = async ( - accessToken: string, - directory: string, - payload: GitHubPullRequestCreateInput, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - throw new Error('Unable to resolve GitHub repo from git remote'); - } - - const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`, accessToken, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: payload.title, - head: payload.head, - base: payload.base, - ...(typeof payload.body === 'string' ? { body: payload.body } : {}), - ...(typeof payload.draft === 'boolean' ? { draft: payload.draft } : {}), - }), - }); - - const json = await jsonOrNull(resp); - if (!resp.ok || !json) { - throw new Error('Failed to create PR'); - } - - return { - number: typeof json.number === 'number' ? json.number : 0, - title: readString(json.title) || '', - body: readString(json.body) || '', - url: readString(json.html_url) || '', - state: readString(json.state) === 'closed' ? 'closed' : 'open', - draft: Boolean(json.draft), - base: readString((json.base as JsonRecord | undefined)?.ref) || payload.base, - head: readString((json.head as JsonRecord | undefined)?.ref) || payload.head, - headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined, - mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null, - mergeableState: readString(json.mergeable_state) || undefined, - }; -}; - -export const updatePullRequest = async ( - accessToken: string, - directory: string, - payload: GitHubPullRequestUpdateInput, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - throw new Error('Unable to resolve GitHub repo from git remote'); - } - - const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}`, accessToken, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: payload.title, - ...(typeof payload.body === 'string' ? { body: payload.body } : {}), - }), - }); - - if (resp.status === 403) { - throw new Error('Not authorized to edit this PR'); - } - if (resp.status === 401) { - const error = new Error('unauthorized'); - (error as unknown as { status?: number }).status = 401; - throw error; - } - - const json = await jsonOrNull(resp); - if (!resp.ok || !json) { - const message = readString(json?.message); - const firstError = Array.isArray(json?.errors) && json.errors.length > 0 - ? readString((json.errors[0] as JsonRecord)?.message || (json.errors[0] as JsonRecord)?.code) - : ''; - const details = [message, firstError].filter(Boolean).join(' · '); - throw new Error(details || 'Failed to update PR'); - } - - const merged = Boolean(json.merged || json.merged_at); - const state = merged ? 'merged' : (readString(json.state) === 'closed' ? 'closed' : 'open'); - - return { - number: typeof json.number === 'number' ? json.number : payload.number, - title: readString(json.title) || payload.title, - body: readString(json.body) || '', - url: readString(json.html_url) || '', - state, - draft: Boolean(json.draft), - base: readString((json.base as JsonRecord | undefined)?.ref) || '', - head: readString((json.head as JsonRecord | undefined)?.ref) || '', - headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined, - mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null, - mergeableState: readString(json.mergeable_state) || undefined, - }; -}; - -export const mergePullRequest = async ( - accessToken: string, - directory: string, - payload: GitHubPullRequestMergeInput, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - throw new Error('Unable to resolve GitHub repo from git remote'); - } - - const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}/merge`, accessToken, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ merge_method: payload.method }), - }); - - if (resp.status === 403) { - throw new Error('Not authorized to merge this PR'); - } - if (resp.status === 405 || resp.status === 409) { - return { merged: false, message: 'PR not mergeable' }; - } - const json = await jsonOrNull(resp); - if (!resp.ok || !json) { - throw new Error('Failed to merge PR'); - } - return { merged: Boolean(json.merged), message: readString(json.message) || undefined }; -}; - -export const markPullRequestReady = async ( - accessToken: string, - directory: string, - number: number, -): Promise<{ ready: boolean }> => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - throw new Error('Unable to resolve GitHub repo from git remote'); - } - - const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken); - if (prResp.status === 401) { - return { ready: false }; - } - const prJson = await jsonOrNull(prResp); - const nodeId = typeof prJson?.node_id === 'string' ? prJson.node_id : ''; - const isDraft = Boolean((prJson as Record | null)?.draft); - if (!prResp.ok || !nodeId) { - throw new Error('Failed to resolve PR node id'); - } - - if (!isDraft) { - return { ready: true }; - } - - const resp = await githubFetch(`${API_BASE}/graphql`, accessToken, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: - 'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }', - variables: { pullRequestId: nodeId }, - }), - }); - - if (resp.status === 403) { - throw new Error('Not authorized to mark PR ready'); - } - if (resp.status === 401) { - const error = new Error('unauthorized'); - (error as unknown as { status?: number }).status = 401; - throw error; - } - const json = await jsonOrNull(resp); - if (!resp.ok || !json) { - throw new Error('Failed to mark PR ready'); - } - if (json.errors) { - throw new Error('GitHub GraphQL error'); - } - return { ready: true }; -}; diff --git a/packages/vscode/src/githubPulls.ts b/packages/vscode/src/githubPulls.ts deleted file mode 100644 index b599c53a..00000000 --- a/packages/vscode/src/githubPulls.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { resolveRepoFromDirectory } from './githubPr'; - -const API_BASE = 'https://api.github.com'; - -type JsonRecord = Record; - -type GitHubRepoRef = { owner: string; repo: string; url: string }; - -type GitHubUserSummary = { login: string; id?: number; avatarUrl?: string; name?: string; email?: string }; - -type GitHubChecksSummary = { - state: 'success' | 'failure' | 'pending' | 'unknown'; - total: number; - success: number; - failure: number; - pending: number; -}; - -type GitHubCheckRun = { - id?: number; - name: string; - app?: { - name?: string; - slug?: string; - }; - status?: string; - conclusion?: string | null; - detailsUrl?: string; - output?: { - title?: string; - summary?: string; - text?: string; - }; - job?: { - runId?: number; - jobId?: number; - url?: string; - name?: string; - conclusion?: string | null; - steps?: Array<{ - name: string; - status?: string; - conclusion?: string | null; - number?: number; - startedAt?: string; - completedAt?: string; - }>; - }; - annotations?: Array<{ - path?: string; - startLine?: number; - endLine?: number; - level?: string; - message: string; - title?: string; - rawDetails?: string; - }>; -}; - -type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string; sshUrl?: string }; - -type GitHubPullRequestSummary = { - number: number; - title: string; - url: string; - state: 'open' | 'closed' | 'merged'; - draft: boolean; - base: string; - head: string; - headSha?: string; - mergeable?: boolean | null; - mergeableState?: string | null; - author?: GitHubUserSummary | null; - body?: string; - createdAt?: string; - updatedAt?: string; - headLabel?: string; - headRepo?: GitHubPullRequestHeadRepo | null; -}; - -type GitHubIssueComment = { - id: number; - url: string; - body: string; - author?: GitHubUserSummary | null; - createdAt?: string; - updatedAt?: string; -}; - -type GitHubPullRequestReviewComment = { - id: number; - url: string; - body: string; - author?: GitHubUserSummary | null; - path?: string; - line?: number | null; - position?: number | null; - createdAt?: string; - updatedAt?: string; -}; - -type GitHubPullRequestFile = { - filename: string; - status?: string; - additions?: number; - deletions?: number; - changes?: number; - patch?: string; -}; - -export type GitHubPullRequestsListResult = { - connected: boolean; - repo?: GitHubRepoRef | null; - prs?: GitHubPullRequestSummary[]; - page?: number; - hasMore?: boolean; -}; - -export type GitHubPullRequestContextResult = { - connected: boolean; - repo?: GitHubRepoRef | null; - pr?: GitHubPullRequestSummary | null; - issueComments?: GitHubIssueComment[]; - reviewComments?: GitHubPullRequestReviewComment[]; - files?: GitHubPullRequestFile[]; - diff?: string; - checks?: GitHubChecksSummary | null; - checkRuns?: GitHubCheckRun[]; -}; - -const githubFetch = async ( - url: string, - accessToken: string, - init?: RequestInit, -): Promise => { - return fetch(url, { - ...init, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - ...(init?.headers || {}), - }, - }); -}; - -const githubFetchText = async ( - url: string, - accessToken: string, - accept: string, -): Promise => { - return fetch(url, { - headers: { - Accept: accept, - Authorization: `Bearer ${accessToken}`, - 'User-Agent': 'OpenChamber', - }, - }); -}; - -const jsonOrNull = async (response: Response): Promise => { - return (await response.json().catch(() => null)) as T | null; -}; - -const readString = (value: unknown): string => (typeof value === 'string' ? value : ''); - -const mapUser = (raw: unknown): GitHubUserSummary | null => { - const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null; - const login = readString(rec?.login); - if (!login) return null; - return { - login, - id: typeof rec?.id === 'number' ? rec.id : undefined, - avatarUrl: readString(rec?.avatar_url) || undefined, - name: undefined, - email: undefined, - }; -}; - -const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => { - const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null; - const ownerLogin = readString((rec?.owner as JsonRecord | undefined)?.login); - const repo = readString(rec?.name); - const url = readString(rec?.html_url); - if (!ownerLogin || !repo || !url) return null; - return { - owner: ownerLogin, - repo, - url, - cloneUrl: readString(rec?.clone_url) || undefined, - sshUrl: readString(rec?.ssh_url) || undefined, - }; -}; - -const computeChecks = async ( - accessToken: string, - repo: GitHubRepoRef, - sha: string -): Promise<{ summary: GitHubChecksSummary | null; runs: GitHubCheckRun[] }> => { - const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken); - if (runsResp.status === 401) { - return { summary: null, runs: [] }; - } - const runsJson = await jsonOrNull(runsResp); - const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs) - ? ((runsJson as JsonRecord).check_runs as unknown[]) - : []; - - const mappedRuns: GitHubCheckRun[] = runs - .map((r) => { - const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null; - const name = readString(rec?.name); - if (!name) return null; - const output = rec?.output && typeof rec.output === 'object' ? (rec.output as JsonRecord) : null; - const app = rec?.app && typeof rec.app === 'object' ? (rec.app as JsonRecord) : null; - return { - id: typeof rec?.id === 'number' ? rec.id : undefined, - name, - app: app - ? { - name: readString(app.name) || undefined, - slug: readString(app.slug) || undefined, - } - : undefined, - status: readString(rec?.status) || undefined, - conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') ? (rec?.conclusion as string | null) : undefined, - detailsUrl: readString(rec?.details_url) || undefined, - output: output - ? { - title: readString(output.title) || undefined, - summary: readString(output.summary) || undefined, - text: readString(output.text) || undefined, - } - : undefined, - }; - }) - .filter(Boolean) as GitHubCheckRun[]; - - if (runsResp.ok && runs.length > 0) { - const counts = { success: 0, failure: 0, pending: 0 }; - runs.forEach((r) => { - const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null; - const status = readString(rec?.status); - const conclusion = readString(rec?.conclusion); - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - return; - } - if (!conclusion) { - counts.pending += 1; - return; - } - 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')); - return { summary: { state, total, ...counts }, runs: mappedRuns }; - } - - const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken); - const statusJson = await jsonOrNull(statusResp); - if (!statusResp.ok || !statusJson) return { summary: null, runs: mappedRuns }; - const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - const st = readString((s as JsonRecord | null)?.state); - if (st === 'success') counts.success += 1; - else if (st === 'failure' || st === 'error') counts.failure += 1; - else if (st === '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')); - return { summary: { state, total, ...counts }, runs: mappedRuns }; -}; - -export const listPullRequests = async ( - accessToken: string, - directory: string, - page: number = 1, - searchQuery?: string, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, prs: [] }; - } - - if (searchQuery) { - const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:pr state:open`; - const searchUrl = new URL(`${API_BASE}/search/issues`); - searchUrl.searchParams.set('q', q); - searchUrl.searchParams.set('per_page', '50'); - searchUrl.searchParams.set('page', String(page)); - - const searchResp = await githubFetch(searchUrl.toString(), accessToken); - if (searchResp.status === 401) return { connected: false }; - - const searchJson = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(searchResp); - if (!searchResp.ok || !searchJson) { - throw new Error('Failed to search PRs'); - } - - const totalCount = typeof searchJson.total_count === 'number' ? searchJson.total_count : 0; - const searchItems = Array.isArray(searchJson.items) ? searchJson.items : []; - const prNumbers = searchItems.map((item) => { - const rec = item && typeof item === 'object' ? (item as JsonRecord) : null; - return typeof rec?.number === 'number' ? rec.number : 0; - }).filter((n) => n > 0); - - let prs: GitHubPullRequestSummary[] = []; - if (prNumbers.length > 0) { - const entries = await Promise.all(prNumbers.map(async (number) => { - const prUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`); - const prResp = await githubFetch(prUrl.toString(), accessToken); - if (prResp.status === 401) return 'unauthorized' as const; - if (!prResp.ok) return null; - return jsonOrNull(prResp); - })); - if (entries.includes('unauthorized')) return { connected: false }; - prs = entries - .filter((entry): entry is JsonRecord => Boolean(entry) && entry !== 'unauthorized') - .map((rec) => { - const number = typeof rec?.number === 'number' ? rec.number : 0; - const mergedAt = readString(rec?.merged_at); - const stateRaw = readString(rec?.state); - const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open'); - const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null; - const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null; - return { - number, - title: readString(rec?.title) || '', - url: readString(rec?.html_url) || '', - state, - draft: Boolean(rec?.draft), - base: readString(base?.ref) || '', - head: readString(head?.ref) || '', - headSha: readString(head?.sha) || undefined, - mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null, - mergeableState: readString(rec?.mergeable_state) || undefined, - author: mapUser(rec?.user), - headLabel: readString(head?.label) || undefined, - headRepo: mapHeadRepo(head?.repo), - } as GitHubPullRequestSummary; - }); - } - - const fetchedCount = (page - 1) * 50 + searchItems.length; - const hasMore = fetchedCount < totalCount; - return { connected: true, repo, prs, page, hasMore }; - } - - const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - url.searchParams.set('state', 'open'); - url.searchParams.set('per_page', '50'); - url.searchParams.set('page', String(page)); - - const resp = await githubFetch(url.toString(), accessToken); - if (resp.status === 401) return { connected: false }; - - const link = resp.headers.get('link') || ''; - const hasMore = /rel="next"/.test(link); - const json = await jsonOrNull(resp); - if (!resp.ok || !Array.isArray(json)) throw new Error('Failed to load PRs'); - - const prs = json.map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - const number = typeof rec?.number === 'number' ? rec.number : 0; - const mergedAt = readString(rec?.merged_at); - const stateRaw = readString(rec?.state); - const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open'); - - const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null; - const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null; - - return { - number, - title: readString(rec?.title) || '', - url: readString(rec?.html_url) || '', - state, - draft: Boolean(rec?.draft), - base: readString(base?.ref) || '', - head: readString(head?.ref) || '', - headSha: readString(head?.sha) || undefined, - mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null, - mergeableState: readString(rec?.mergeable_state) || undefined, - author: mapUser(rec?.user), - headLabel: readString(head?.label) || undefined, - headRepo: mapHeadRepo(head?.repo), - } as GitHubPullRequestSummary; - }); - - return { connected: true, repo, prs, page, hasMore }; -}; - -export const getPullRequestContext = async ( - accessToken: string, - directory: string, - number: number, - includeDiff: boolean, - includeCheckDetails: boolean, -): Promise => { - const repo = await resolveRepoFromDirectory(directory); - if (!repo) { - return { connected: true, repo: null, pr: null }; - } - - const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken); - if (prResp.status === 401) return { connected: false }; - const prJson = await jsonOrNull(prResp); - if (!prResp.ok || !prJson) throw new Error('Failed to load PR'); - - const merged = Boolean(prJson.merged_at) || Boolean(prJson.merged); - const prState = readString(prJson.state); - const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open'); - const base = prJson.base && typeof prJson.base === 'object' ? (prJson.base as JsonRecord) : null; - const head = prJson.head && typeof prJson.head === 'object' ? (prJson.head as JsonRecord) : null; - - const pr: GitHubPullRequestSummary = { - number: typeof prJson.number === 'number' ? prJson.number : number, - title: readString(prJson.title) || '', - url: readString(prJson.html_url) || '', - state, - draft: Boolean(prJson.draft), - base: readString(base?.ref) || '', - head: readString(head?.ref) || '', - headSha: readString(head?.sha) || undefined, - mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null, - mergeableState: readString(prJson.mergeable_state) || undefined, - author: mapUser(prJson.user), - headLabel: readString(head?.label) || undefined, - headRepo: mapHeadRepo(head?.repo), - body: readString(prJson.body) || '', - createdAt: readString(prJson.created_at) || undefined, - updatedAt: readString(prJson.updated_at) || undefined, - }; - - const issueCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments?per_page=100`, accessToken); - if (issueCommentsResp.status === 401) return { connected: false }; - const issueCommentsJson = await jsonOrNull(issueCommentsResp); - if (!issueCommentsResp.ok || !Array.isArray(issueCommentsJson)) throw new Error('Failed to load PR issue comments'); - const issueComments: GitHubIssueComment[] = issueCommentsJson - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - const id = typeof rec?.id === 'number' ? rec.id : 0; - if (!id) return null; - return { - id, - url: readString(rec?.html_url) || '', - body: readString(rec?.body) || '', - author: mapUser(rec?.user), - createdAt: readString(rec?.created_at) || undefined, - updatedAt: readString(rec?.updated_at) || undefined, - }; - }) - .filter(Boolean) as GitHubIssueComment[]; - - const reviewCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/comments?per_page=100`, accessToken); - if (reviewCommentsResp.status === 401) return { connected: false }; - const reviewCommentsJson = await jsonOrNull(reviewCommentsResp); - if (!reviewCommentsResp.ok || !Array.isArray(reviewCommentsJson)) throw new Error('Failed to load PR review comments'); - const reviewComments: GitHubPullRequestReviewComment[] = reviewCommentsJson - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - const id = typeof rec?.id === 'number' ? rec.id : 0; - if (!id) return null; - return { - id, - url: readString(rec?.html_url) || '', - body: readString(rec?.body) || '', - author: mapUser(rec?.user), - path: readString(rec?.path) || undefined, - line: typeof rec?.line === 'number' ? rec.line : null, - position: typeof rec?.position === 'number' ? rec.position : null, - createdAt: readString(rec?.created_at) || undefined, - updatedAt: readString(rec?.updated_at) || undefined, - }; - }) - .filter(Boolean) as GitHubPullRequestReviewComment[]; - - const filesResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/files?per_page=100`, accessToken); - if (filesResp.status === 401) return { connected: false }; - const filesJson = await jsonOrNull(filesResp); - if (!filesResp.ok || !Array.isArray(filesJson)) throw new Error('Failed to load PR files'); - const files: GitHubPullRequestFile[] = filesJson - .map((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - const filename = readString(rec?.filename); - if (!filename) return null; - return { - filename, - status: readString(rec?.status) || undefined, - additions: typeof rec?.additions === 'number' ? rec.additions : undefined, - deletions: typeof rec?.deletions === 'number' ? rec.deletions : undefined, - changes: typeof rec?.changes === 'number' ? rec.changes : undefined, - patch: readString(rec?.patch) || undefined, - }; - }) - .filter(Boolean) as GitHubPullRequestFile[]; - - const checksResult = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : { summary: null, runs: [] }; - const checks = checksResult.summary; - const checkRuns = checksResult.runs; - - if (includeCheckDetails && checkRuns.length > 0) { - const parseIds = (url: string | undefined): { runId: number | null; jobId: number | null } => { - if (!url) return { runId: null, jobId: null }; - const match = url.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); - if (!match) return { runId: null, jobId: null }; - const runId = Number(match[1]); - const jobId = match[2] ? Number(match[2]) : null; - return { - runId: Number.isFinite(runId) && runId > 0 ? runId : null, - jobId: jobId && Number.isFinite(jobId) && jobId > 0 ? jobId : null, - }; - }; - - const jobsByRunId = new Map(); - const runIds = new Set(); - checkRuns.forEach((r) => { - const ids = parseIds(r.detailsUrl); - if (ids.runId) runIds.add(ids.runId); - }); - - for (const runId of runIds) { - const jobsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/actions/runs/${runId}/jobs?per_page=100`, accessToken); - if (jobsResp.status === 401) { - return { connected: false }; - } - const jobsJson = await jsonOrNull(jobsResp); - const jobs = Array.isArray(jobsJson?.jobs) ? (jobsJson?.jobs as unknown[]) : []; - jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]); - } - - const annotationsByRunId = new Map>(); - - for (const run of checkRuns) { - const runId = typeof run.id === 'number' ? run.id : 0; - const conclusion = (run.conclusion || '').toLowerCase(); - const shouldLoadAnnotations = Boolean( - runId > 0 - && conclusion - && !['success', 'neutral', 'skipped'].includes(conclusion), - ); - if (!shouldLoadAnnotations) { - continue; - } - - const annotations: Array<{ - path?: string; - startLine?: number; - endLine?: number; - level?: string; - message: string; - title?: string; - rawDetails?: string; - }> = []; - - for (let page = 1; page <= 3; page += 1) { - const annotationsResp = await githubFetch( - `${API_BASE}/repos/${repo.owner}/${repo.repo}/check-runs/${runId}/annotations?per_page=50&page=${page}`, - accessToken, - ); - if (annotationsResp.status === 401) { - return { connected: false }; - } - const annotationsJson = await jsonOrNull(annotationsResp); - const chunk = Array.isArray(annotationsJson) ? annotationsJson : []; - chunk.forEach((entry) => { - const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; - const message = readString(rec?.message); - if (!message) return; - annotations.push({ - path: readString(rec?.path) || undefined, - startLine: typeof rec?.start_line === 'number' ? rec.start_line : undefined, - endLine: typeof rec?.end_line === 'number' ? rec.end_line : undefined, - level: readString(rec?.annotation_level) || undefined, - message, - title: readString(rec?.title) || undefined, - rawDetails: readString(rec?.raw_details) || undefined, - }); - }); - if (chunk.length < 50) { - break; - } - } - - if (annotations.length > 0) { - annotationsByRunId.set(runId, annotations); - } - } - - for (const run of checkRuns) { - if (run.id && annotationsByRunId.has(run.id)) { - run.annotations = annotationsByRunId.get(run.id); - } - - const ids = parseIds(run.detailsUrl); - if (!ids.runId) continue; - const jobs = jobsByRunId.get(ids.runId) ?? []; - const picked = ids.jobId - ? jobs.find((j) => typeof j.id === 'number' && j.id === ids.jobId) - : jobs.find((j) => readString(j.name) === run.name); - if (!picked) { - run.job = { runId: ids.runId, ...(ids.jobId ? { jobId: ids.jobId } : {}), url: run.detailsUrl }; - continue; - } - const stepsRaw = Array.isArray(picked.steps) ? (picked.steps as unknown[]) : []; - const steps = stepsRaw - .map((s) => { - const rec = s && typeof s === 'object' ? (s as JsonRecord) : null; - const name = readString(rec?.name); - if (!name) return null; - return { - name, - status: readString(rec?.status) || undefined, - conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') - ? (rec?.conclusion as string | null) - : undefined, - number: typeof rec?.number === 'number' ? rec.number : undefined, - startedAt: readString(rec?.started_at) || undefined, - completedAt: readString(rec?.completed_at) || undefined, - }; - }) - .filter(Boolean) as Array<{ - name: string; - status?: string; - conclusion?: string | null; - number?: number; - startedAt?: string; - completedAt?: string; - }>; - - run.job = { - runId: ids.runId, - jobId: typeof picked.id === 'number' ? picked.id : undefined, - url: readString(picked.html_url) || undefined, - name: readString(picked.name) || undefined, - conclusion: (picked.conclusion === null || typeof picked.conclusion === 'string') - ? (picked.conclusion as string | null) - : undefined, - steps: steps.length > 0 ? steps : undefined, - }; - - } - } - - let diff: string | undefined; - if (includeDiff) { - const diffResp = await githubFetchText(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken, 'application/vnd.github.v3.diff'); - if (diffResp.status === 401) return { connected: false }; - if (diffResp.ok) { - diff = await diffResp.text().catch(() => undefined); - } - } - - return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks, checkRuns }; -}; diff --git a/packages/vscode/src/opencode-ready.ts b/packages/vscode/src/opencode-ready.ts index 0434dcdd..d477057a 100644 --- a/packages/vscode/src/opencode-ready.ts +++ b/packages/vscode/src/opencode-ready.ts @@ -1,6 +1,6 @@ import type { ConnectionStatus, OpenCodeManager } from './opencode'; -export const API_URL_WAIT_TIMEOUT_MS = 30000; +const API_URL_WAIT_TIMEOUT_MS = 30000; export async function waitForApiUrl( manager: OpenCodeManager | undefined, diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index efbaa374..540a9aba 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -21,7 +21,7 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.C .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)); export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type OpenCodeDebugInfo = { +type OpenCodeDebugInfo = { mode: 'managed' | 'external'; status: ConnectionStatus; lastError?: string; @@ -47,7 +47,7 @@ export type OpenCodeDebugInfo = { authSource: 'user-env' | 'generated' | 'rotated' | null; }; -export type SetWorkingDirectoryResult = +type SetWorkingDirectoryResult = | { success: true; path: string } | { success: false; error: string }; diff --git a/packages/vscode/src/opencodeAuth.ts b/packages/vscode/src/opencodeAuth.ts index b9a21221..2563c978 100644 --- a/packages/vscode/src/opencodeAuth.ts +++ b/packages/vscode/src/opencodeAuth.ts @@ -63,10 +63,3 @@ export const getProviderAuth = (providerId: string): AuthEntry | null => { const auth = readAuthFile(); return auth[providerId] || null; }; - -export const listProviderAuths = (): string[] => { - const auth = readAuthFile(); - return Object.keys(auth); -}; - -export { AUTH_FILE, OPENCODE_DATA_DIR }; diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index a4d8e5c7..9d9c8b5c 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -45,7 +45,7 @@ export type Snippet = { }; export type PluginScope = 'user' | 'project'; -export type PluginParsedKind = 'npm' | 'path'; +type PluginParsedKind = 'npm' | 'path'; export type PluginEntry = { id: string; @@ -200,7 +200,7 @@ const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = glo return pluralPath; }; -export const getAgentScope = ( +const getAgentScope = ( agentName: string, workingDirectory?: string, lookupCache: AgentLookupCache = globalAgentLookupCache @@ -273,7 +273,7 @@ const getUserCommandPath = (commandName: string): string => { return pluralPath; }; -export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => { +const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => { if (workingDirectory) { const projectPath = getProjectCommandPath(workingDirectory, commandName); if (fs.existsSync(projectPath)) { @@ -1194,23 +1194,6 @@ export const queryPluginRegistry = async ( return { results }; }; -export type McpLocalConfig = { - type: 'local'; - command?: string[]; - environment?: Record; - enabled?: boolean; -}; - -export type McpRemoteConfig = { - type: 'remote'; - url?: string; - environment?: Record; - headers?: Record; - enabled?: boolean; -}; - -export type McpConfigPayload = McpLocalConfig | McpRemoteConfig; - export type McpConfigEntry = { name: string; scope?: AgentScope | null; @@ -2241,7 +2224,7 @@ export const SKILL_SCOPE = { export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE]; export type SkillSource = 'opencode' | 'claude' | 'agents'; -export type SupportingFile = { +type SupportingFile = { name: string; path: string; fullPath: string; @@ -2382,9 +2365,9 @@ const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string): return path.join(workingDirectory, '.agents', 'skills', skillName); }; -export const getSkillScope = (skillName: string, workingDirectory?: string): { - scope: SkillScope | null; - path: string | null; +const getSkillScope = (skillName: string, workingDirectory?: string): { + scope: SkillScope | null; + path: string | null; source: SkillSource | null; } => { const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 6d33f374..3bc18af9 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -456,7 +456,7 @@ export const listConfiguredQuotaProviders = () => { return Array.from(configured); }; -export const fetchCodexQuota = async (): Promise => { +const fetchCodexQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])) as Record | null; const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); @@ -723,7 +723,7 @@ const fetchGoogleModels = async (accessToken: string, projectId?: string) => { return null; }; -export const fetchGoogleQuota = async (): Promise => { +const fetchGoogleQuota = async (): Promise => { const authSources = resolveGoogleAuthSources(); if (!authSources.length) { return buildResult({ @@ -851,7 +851,7 @@ export const fetchGoogleQuota = async (): Promise => { }); }; -export const fetchClaudeQuota = async (): Promise => { +const fetchClaudeQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record | null; const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); @@ -969,7 +969,7 @@ const buildCopilotWindows = (payload: Record) => { return windows; }; -export const fetchCopilotQuota = async (): Promise => { +const fetchCopilotQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record | null; const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); @@ -1024,7 +1024,7 @@ export const fetchCopilotQuota = async (): Promise => { } }; -export const fetchCopilotAddonQuota = async (): Promise => { +const fetchCopilotAddonQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record | null; const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); @@ -1082,7 +1082,7 @@ export const fetchCopilotAddonQuota = async (): Promise => { } }; -export const fetchKimiQuota = async (): Promise => { +const fetchKimiQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); @@ -1293,14 +1293,14 @@ const fetchMiniMaxQuota = async (data: { } }; -export const fetchMiniMaxCodingPlanQuota = () => fetchMiniMaxQuota({ +const fetchMiniMaxCodingPlanQuota = () => fetchMiniMaxQuota({ providerId: 'minimax-coding-plan', providerName: 'MiniMax Coding Plan (minimax.io)', endpoint: 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains', usageFieldsAreRemaining: false, }); -export const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({ +const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({ providerId: 'minimax-cn-coding-plan', providerName: 'MiniMax Coding Plan (minimaxi.com)', endpoint: 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains', @@ -1343,7 +1343,7 @@ const parseOllamaSettingsHtml = (html: string) => { return windows; }; -export const fetchOllamaCloudQuota = async (): Promise => { +const fetchOllamaCloudQuota = async (): Promise => { const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH); if (!cookie) { @@ -1393,7 +1393,7 @@ export const fetchOllamaCloudQuota = async (): Promise => { } }; -export const fetchOpenRouterQuota = async (): Promise => { +const fetchOpenRouterQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); @@ -1491,7 +1491,7 @@ const resolveWindowLabel = (windowSeconds: number | null) => { return `${windowSeconds}s`; }; -export const fetchZaiQuota = async (): Promise => { +const fetchZaiQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); @@ -1560,7 +1560,7 @@ export const fetchZaiQuota = async (): Promise => { } }; -export const fetchZhipuaiCodingPlanQuota = async (): Promise => { +const fetchZhipuaiCodingPlanQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['zhipuai-coding-plan'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); @@ -1649,7 +1649,7 @@ export const fetchZhipuaiCodingPlanQuota = async (): Promise => const NANO_GPT_DAILY_WINDOW_SECONDS = 86400; -export const fetchNanoGptQuota = async (): Promise => { +const fetchNanoGptQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['nano-gpt', 'nanogpt', 'nano_gpt'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); @@ -1755,7 +1755,7 @@ export const fetchNanoGptQuota = async (): Promise => { const WAFER_QUOTA_URL = 'https://pass.wafer.ai/v1/inference/quota'; const WAFER_WINDOW_SECONDS = 5 * 3600; -export const fetchWaferQuota = async (): Promise => { +const fetchWaferQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'])) as Record | null; const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index bccdc0dc..a61c887a 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -4,7 +4,6 @@ import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import yaml from 'yaml'; -import AdmZip from 'adm-zip'; import { discoverSkills } from './opencodeConfig'; @@ -34,7 +33,7 @@ type SkillFrontmatter = { [key: string]: unknown; }; -export type ClawdHubSkillMetadata = { +type ClawdHubSkillMetadata = { slug: string; version: string; displayName?: string; @@ -43,7 +42,7 @@ export type ClawdHubSkillMetadata = { stars?: number; }; -export type SkillsCatalogItem = { +type SkillsCatalogItem = { repoSource: string; repoSubpath?: string; skillDir: string; @@ -76,7 +75,7 @@ type SkillsInstallResult = | { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> } | { ok: false; error: SkillsRepoError }; -export const CURATED_SOURCES: CuratedSource[] = [ +const CURATED_SOURCES: CuratedSource[] = [ { id: 'anthropic', label: 'Anthropic', @@ -222,172 +221,6 @@ async function scanClawdHub(): Promise { } } -async function downloadClawdHubSkill(slug: string, version: string): Promise { - const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`; - const response = await clawdhubFetch(url, { headers: { Accept: 'application/zip' } }); - - if (!response.ok) { - throw new Error(`ClawdHub download error: ${response.status}`); - } - - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); -} - -type ClawdHubSkillInfoResponse = { - skill?: { tags?: { latest?: string } }; - latestVersion?: { version?: string }; -}; - -async function fetchClawdHubSkillInfo(slug: string): Promise { - const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; - const response = await clawdhubFetch(url); - - if (!response.ok) { - throw new Error(`ClawdHub skill error: ${response.status}`); - } - - return response.json() as Promise; -} - -export async function installSkillsFromClawdHub(options: { - scope: SkillScope; - targetSource?: SkillInstallSource; - workingDirectory?: string; - selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>; - conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; - conflictDecisions?: Record; -}): Promise { - if (options.scope === 'project' && !options.workingDirectory) { - return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; - } - - const userSkillDir = getUserSkillBaseDir(); - const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode'; - const requestedSkills = options.selections || []; - - if (requestedSkills.length === 0) { - return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; - } - - // Check for conflicts - const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = []; - for (const sel of requestedSkills) { - const slug = sel.clawdhub?.slug || sel.skillDir; - if (!validateSkillName(slug)) continue; - - const targetDir = options.scope === 'user' - ? (targetSource === 'agents' - ? path.join(os.homedir(), '.agents', 'skills', slug) - : path.join(userSkillDir, slug)) - : (targetSource === 'agents' - ? path.join(options.workingDirectory as string, '.agents', 'skills', slug) - : path.join(options.workingDirectory as string, '.opencode', 'skills', slug)); - - if (fs.existsSync(targetDir)) { - const decision = options.conflictDecisions?.[slug]; - const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll'; - if (!decision && !hasAutoPolicy) { - conflicts.push({ skillName: slug, scope: options.scope, source: targetSource }); - } - } - } - - if (conflicts.length > 0) { - return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } }; - } - - const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = []; - const skipped: Array<{ skillName: string; reason: string }> = []; - - for (const sel of requestedSkills) { - const slug = sel.clawdhub?.slug || sel.skillDir; - let version = sel.clawdhub?.version || 'latest'; - - if (!validateSkillName(slug)) { - skipped.push({ skillName: slug, reason: 'Invalid skill name' }); - continue; - } - - try { - // Resolve 'latest' version - if (version === 'latest') { - try { - const info = await fetchClawdHubSkillInfo(slug); - const latest = info.skill?.tags?.latest || info.latestVersion?.version || null; - if (latest) { - version = latest; - } - } catch { - // ignore - } - - if (version === 'latest') { - skipped.push({ skillName: slug, reason: 'Unable to resolve latest version' }); - continue; - } - } - - const targetDir = options.scope === 'user' - ? (targetSource === 'agents' - ? path.join(os.homedir(), '.agents', 'skills', slug) - : path.join(userSkillDir, slug)) - : (targetSource === 'agents' - ? path.join(options.workingDirectory as string, '.agents', 'skills', slug) - : path.join(options.workingDirectory as string, '.opencode', 'skills', slug)); - - const exists = fs.existsSync(targetDir); - let decision = options.conflictDecisions?.[slug] || null; - if (!decision) { - if (exists && options.conflictPolicy === 'skipAll') decision = 'skip'; - if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite'; - if (!exists) decision = 'overwrite'; - } - - if (exists && decision === 'skip') { - skipped.push({ skillName: slug, reason: 'Already installed (skipped)' }); - continue; - } - - if (exists && decision === 'overwrite') { - await safeRm(targetDir); - } - - // Download and extract - const zipBuffer = await downloadClawdHubSkill(slug, version); - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${slug}-`)); - - try { - const zip = new AdmZip(zipBuffer); - zip.extractAllTo(tempDir, true); - - // Verify SKILL.md exists - const skillMdPath = path.join(tempDir, 'SKILL.md'); - if (!fs.existsSync(skillMdPath)) { - skipped.push({ skillName: slug, reason: 'SKILL.md not found in downloaded package' }); - continue; - } - - // Move to target directory - await fs.promises.mkdir(path.dirname(targetDir), { recursive: true }); - await fs.promises.rename(tempDir, targetDir); - - installed.push({ skillName: slug, scope: options.scope, source: targetSource }); - } catch (extractError) { - await safeRm(tempDir); - throw extractError; - } - } catch (error) { - skipped.push({ - skillName: slug, - reason: error instanceof Error ? error.message : 'Failed to download or extract skill', - }); - } - } - - return { ok: true, installed, skipped }; -} - function validateSkillName(skillName: string): boolean { if (skillName.length < 1 || skillName.length > 64) return false; return SKILL_NAME_PATTERN.test(skillName); @@ -957,5 +790,3 @@ export async function getSkillsCatalog( return { ok: true as const, sources, itemsBySource }; } - -export { isClawdHubSource }; diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index e383333a..322edf0b 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -4,7 +4,7 @@ import { getThemeKindName } from './theme'; import type { ConnectionStatus } from './opencode'; import type { WorkspaceFolderCandidate } from './workspaceResolver'; -export type PanelType = 'chat' | 'agentManager'; +type PanelType = 'chat' | 'agentManager'; export interface WebviewHtmlOptions { webview: vscode.Webview; diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index 6e0a6dfd..e63b5f64 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -17,11 +17,6 @@ function getVSCodeAPI(): VSCodeAPI { return vscodeApi; } -// Export vscode API for direct use -export const vscode = { - postMessage: (message: unknown) => getVSCodeAPI().postMessage(message), -}; - interface BridgeRequest { id: string; type: string; diff --git a/packages/vscode/webview/api/streamPerf.ts b/packages/vscode/webview/api/streamPerf.ts index 460702f9..6cadb31f 100644 --- a/packages/vscode/webview/api/streamPerf.ts +++ b/packages/vscode/webview/api/streamPerf.ts @@ -19,7 +19,7 @@ declare global { } } -export const vscodeStreamPerfEnabled = (): boolean => { +const vscodeStreamPerfEnabled = (): boolean => { try { return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; } catch { diff --git a/packages/vscode/webview/requestBodyTransport.ts b/packages/vscode/webview/requestBodyTransport.ts index 8e5968e5..8ac4a3c5 100644 --- a/packages/vscode/webview/requestBodyTransport.ts +++ b/packages/vscode/webview/requestBodyTransport.ts @@ -1,4 +1,4 @@ -export const encodeBase64 = (bytes: Uint8Array): string => { +const encodeBase64 = (bytes: Uint8Array): string => { const CHUNK = 0x8000; let binary = ''; for (let i = 0; i < bytes.length; i += CHUNK) { @@ -9,7 +9,7 @@ export const encodeBase64 = (bytes: Uint8Array): string => { export const hasInitBody = (init: RequestInit | undefined): boolean => init?.body !== undefined && init.body !== null; -export const readBodyBytes = async (body: BodyInit): Promise => { +const readBodyBytes = async (body: BodyInit): Promise => { if (typeof body === 'string') { return new TextEncoder().encode(body); } @@ -37,7 +37,7 @@ export const readBodyBytes = async (body: BodyInit): Promise => { throw new Error('Unsupported request body type'); }; -export const readBodyText = async (body: BodyInit): Promise => { +const readBodyText = async (body: BodyInit): Promise => { if (typeof body === 'string') return body; if (body instanceof URLSearchParams) return body.toString(); if (body instanceof Blob) return await body.text(); diff --git a/packages/web/bin/cli-output.js b/packages/web/bin/cli-output.js index 8ba900a4..92a9e845 100644 --- a/packages/web/bin/cli-output.js +++ b/packages/web/bin/cli-output.js @@ -10,10 +10,7 @@ import { intro, outro, log, - note, box, - progress, - spinner, confirm, select, text, @@ -122,17 +119,13 @@ export { intro, outro, log, - note, box, - progress, - spinner, confirm, select, text, password, cancel, isCancel, - isTTY, isJsonMode, isQuietMode, shouldRenderHumanOutput, diff --git a/packages/web/server/lib/cloudflare-tunnel.js b/packages/web/server/lib/cloudflare-tunnel.js index 3b433070..8d1e81af 100644 --- a/packages/web/server/lib/cloudflare-tunnel.js +++ b/packages/web/server/lib/cloudflare-tunnel.js @@ -41,7 +41,7 @@ export async function checkCloudflaredAvailable() { return { available: false, path: null, version: null }; } -export function printCloudflareTunnelInstallHelp() { +function printCloudflareTunnelInstallHelp() { const platform = process.platform; let installCmd = ''; @@ -600,7 +600,7 @@ export async function startCloudflareManagedLocalTunnel({ configPath, hostname } }; } -export async function startCloudflareTunnel({ originUrl, port }) { +async function startCloudflareTunnel({ originUrl, port }) { void port; return startCloudflareQuickTunnel({ originUrl }); } diff --git a/packages/web/server/lib/event-stream/global-hub.js b/packages/web/server/lib/event-stream/global-hub.js index 165dc0b7..423f42ba 100644 --- a/packages/web/server/lib/event-stream/global-hub.js +++ b/packages/web/server/lib/event-stream/global-hub.js @@ -2,7 +2,7 @@ import { createUpstreamSseReader } from './upstream-reader.js'; // Raised from 512 → 2048 to improve recovery after brief disconnects during // long-running agent sessions where many events accumulate quickly. -export const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048; +const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048; export function createGlobalMessageStreamHub({ buildOpenCodeUrl, diff --git a/packages/web/server/lib/event-stream/index.js b/packages/web/server/lib/event-stream/index.js index 06dae6ef..8b277deb 100644 --- a/packages/web/server/lib/event-stream/index.js +++ b/packages/web/server/lib/event-stream/index.js @@ -1,25 +1,13 @@ -export { - MESSAGE_STREAM_GLOBAL_WS_PATH, - MESSAGE_STREAM_DIRECTORY_WS_PATH, - MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS, - parseSseEventEnvelope, - sendMessageStreamWsFrame, - sendMessageStreamWsEvent, -} from './protocol.js'; - export { createGlobalUiEventBroadcaster, createMessageStreamWsRuntime, } from './runtime.js'; export { - MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT, createGlobalMessageStreamHub, } from './global-hub.js'; export { - DEFAULT_UPSTREAM_RECONNECT_DELAY_MS, DEFAULT_UPSTREAM_STALL_TIMEOUT_MS, UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS, - createUpstreamSseReader, } from './upstream-reader.js'; diff --git a/packages/web/server/lib/event-stream/runtime.js b/packages/web/server/lib/event-stream/runtime.js index fc619308..72a28893 100644 --- a/packages/web/server/lib/event-stream/runtime.js +++ b/packages/web/server/lib/event-stream/runtime.js @@ -1,6 +1,6 @@ import { WebSocketServer } from 'ws'; -import { parseRequestPathname } from '../terminal/index.js'; +import { parseRequestPathname } from '../terminal/terminal-ws-protocol.js'; import { MESSAGE_STREAM_DIRECTORY_WS_PATH, MESSAGE_STREAM_GLOBAL_WS_PATH, diff --git a/packages/web/server/lib/notifications/index.js b/packages/web/server/lib/notifications/index.js index 49a8c25d..dade06f5 100644 --- a/packages/web/server/lib/notifications/index.js +++ b/packages/web/server/lib/notifications/index.js @@ -1,4 +1 @@ -export { truncateNotificationText, prepareNotificationLastMessage } from './message.js'; -export { createNotificationTriggerRuntime } from './runtime.js'; -export { createPushRuntime } from './push-runtime.js'; -export { createNotificationTemplateRuntime } from './template-runtime.js'; +export { prepareNotificationLastMessage } from './message.js'; diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js index ff6bdf21..09b0e478 100644 --- a/packages/web/server/lib/opencode/agents.js +++ b/packages/web/server/lib/opencode/agents.js @@ -685,12 +685,6 @@ function deleteAgent(agentName, workingDirectory, scope) { } export { - ensureProjectAgentDir, - getProjectAgentPath, - getUserAgentPath, - getAgentScope, - getAgentWritePath, - getAgentPermissionSource, getAgentSources, getAgentConfig, createAgent, diff --git a/packages/web/server/lib/opencode/commands.js b/packages/web/server/lib/opencode/commands.js index 6c963aa7..e595940a 100644 --- a/packages/web/server/lib/opencode/commands.js +++ b/packages/web/server/lib/opencode/commands.js @@ -327,11 +327,6 @@ function deleteCommand(commandName, workingDirectory) { } export { - ensureProjectCommandDir, - getProjectCommandPath, - getUserCommandPath, - getCommandScope, - getCommandWritePath, getCommandSources, createCommand, updateCommand, diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index ffa05eb3..25de723f 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -13,6 +13,33 @@ import { registerPluginRoutes } from './plugin-routes.js'; import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js'; import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js'; import { registerOpenCodeRoutes } from './routes.js'; +import { getProviderSources, removeProviderConfig } from './providers.js'; +import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js'; +import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js'; +import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js'; +import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js'; +import { + listPluginEntries, + getPluginEntry, + createPluginEntry, + updatePluginEntry, + deletePluginEntry, + listPluginDirFiles, + readPluginDirFile, + writePluginDirFile, + deletePluginDirFile, + encodePluginId, + decodePluginId, +} from './plugins.js'; +import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; +import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js'; +import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; +import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; +import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; +import { scanSkillsRepository } from '../skills-catalog/scan.js'; +import { installSkillsFromRepository } from '../skills-catalog/install.js'; +import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js'; +import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js'; export const createFeatureRoutesRuntime = (dependencies) => { const { @@ -63,8 +90,6 @@ export const createFeatureRoutesRuntime = (dependencies) => { writeSseEvent, } = routeDependencies; - const { getProviderSources, removeProviderConfig } = await import('./index.js'); - registerSettingsUtilityRoutes(app, { readCustomThemesFromDisk, refreshOpenCodeAfterConfigChange, @@ -111,40 +136,6 @@ export const createFeatureRoutesRuntime = (dependencies) => { writeSseEvent, }); - const { - getAgentSources, - getAgentConfig, - createAgent, - updateAgent, - deleteAgent, - getCommandSources, - createCommand, - updateCommand, - deleteCommand, - listMcpConfigs, - getMcpConfig, - createMcpConfig, - updateMcpConfig, - deleteMcpConfig, - listSnippets, - getSnippet, - createSnippet, - updateSnippet, - deleteSnippet, - expandSnippets, - listPluginEntries, - getPluginEntry, - createPluginEntry, - updatePluginEntry, - deletePluginEntry, - listPluginDirFiles, - readPluginDirFile, - writePluginDirFile, - deletePluginDirFile, - encodePluginId, - decodePluginId, - } = await import('./index.js'); - registerConfigEntityRoutes(app, { resolveProjectDirectory, resolveOptionalProjectDirectory, @@ -193,32 +184,6 @@ export const createFeatureRoutesRuntime = (dependencies) => { isExactSemver, }); - const { - getSkillSources, - discoverSkills, - mergeDiscoveredSkills, - 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, { diff --git a/packages/web/server/lib/opencode/index.js b/packages/web/server/lib/opencode/index.js deleted file mode 100644 index b691a74d..00000000 --- a/packages/web/server/lib/opencode/index.js +++ /dev/null @@ -1,95 +0,0 @@ -export { - AGENT_DIR, - COMMAND_DIR, - SKILL_DIR, - CONFIG_FILE, - AGENT_SCOPE, - COMMAND_SCOPE, - SKILL_SCOPE, - readConfig, - writeConfig, - readSkillSupportingFile, - writeSkillSupportingFile, - deleteSkillSupportingFile, -} from './shared.js'; - -export { - getAgentScope, - getAgentPermissionSource, - getAgentSources, - getAgentConfig, - createAgent, - updateAgent, - deleteAgent, -} from './agents.js'; - -export { - getCommandScope, - getCommandSources, - createCommand, - updateCommand, - deleteCommand, -} from './commands.js'; - -export { - getSkillSources, - getSkillScope, - discoverSkills, - mergeDiscoveredSkills, - createSkill, - updateSkill, - deleteSkill, -} from './skills.js'; - -export { - getProviderSources, - removeProviderConfig, -} from './providers.js'; - -export { - readAuthFile, - writeAuthFile, - removeProviderAuth, - getProviderAuth, - listProviderAuths, - AUTH_FILE, - OPENCODE_DATA_DIR, -} from './auth.js'; - -export { createUiAuth } from '../ui-auth/ui-auth.js'; - -export { - listMcpConfigs, - getMcpConfig, - createMcpConfig, - updateMcpConfig, - deleteMcpConfig, -} from './mcp.js'; - -export { - listPluginEntries, - getPluginEntry, - createPluginEntry, - updatePluginEntry, - deletePluginEntry, - listPluginDirFiles, - readPluginDirFile, - writePluginDirFile, - deletePluginDirFile, - encodePluginId, - decodePluginId, - parsePluginRaw, - serializePluginEntry, -} from './plugins.js'; - -export { - listSnippets, - getSnippet, - createSnippet, - updateSnippet, - deleteSnippet, - expandSnippets, -} from './snippets.js'; - -export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js'; -export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js'; diff --git a/packages/web/server/lib/opencode/npm-registry.js b/packages/web/server/lib/opencode/npm-registry.js index 9337411e..515a6651 100644 --- a/packages/web/server/lib/opencode/npm-registry.js +++ b/packages/web/server/lib/opencode/npm-registry.js @@ -2,9 +2,9 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -export const NPM_CACHE_TTL_MS = 3_600_000; -export const NPM_FETCH_TIMEOUT_MS = 5_000; -export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org'; +const NPM_CACHE_TTL_MS = 3_600_000; +const NPM_FETCH_TIMEOUT_MS = 5_000; +const NPM_REGISTRY_BASE = 'https://registry.npmjs.org'; /** * @typedef {Object} NpmPackagePayload diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index bb5e87aa..3351a5f6 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -50,7 +50,7 @@ export const normalizeForwardedDirectoryHeaders = (headers) => { return headers; }; -export const waitForSseDrain = (res, signal) => new Promise((resolve) => { +const waitForSseDrain = (res, signal) => new Promise((resolve) => { if (signal?.aborted || res.writableEnded || res.destroyed) { resolve(); return; @@ -132,7 +132,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [ 'project', ]; -export const sanitizeSessionListItem = (session) => { +const sanitizeSessionListItem = (session) => { if (!session || typeof session !== 'object' || Array.isArray(session)) { return session; } @@ -168,7 +168,7 @@ export const sanitizeSessionListItem = (session) => { return sanitized; }; -export const sanitizeSessionListPayload = (payload) => { +const sanitizeSessionListPayload = (payload) => { if (!Array.isArray(payload)) { return payload; } diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 164615a0..8f499977 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -507,20 +507,14 @@ export { COMMAND_DIR, SKILL_DIR, CONFIG_FILE, - CUSTOM_CONFIG_FILE, - PROMPT_FILE_PATTERN, AGENT_SCOPE, COMMAND_SCOPE, SKILL_SCOPE, ensureDirs, parseMdFile, writeMdFile, - getProjectConfigCandidates, - getProjectConfigPath, - getConfigPaths, readConfigFile, isPlainObject, - mergeConfigs, readConfigLayers, readConfig, getConfigForPath, diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index 30dfd558..91027b52 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -594,8 +594,6 @@ function deleteSkill(skillName, workingDirectory) { export { getSkillSources, - getSkillScope, - getSkillWritePath, discoverSkills, mergeDiscoveredSkills, createSkill, diff --git a/packages/web/server/lib/opencode/snippets.js b/packages/web/server/lib/opencode/snippets.js index bbef3c7a..08f1dcd2 100644 --- a/packages/web/server/lib/opencode/snippets.js +++ b/packages/web/server/lib/opencode/snippets.js @@ -240,5 +240,3 @@ export function expandSnippets(text, workingDirectory) { const expanded = expandText(text || '', registry, new Map(), collector).trim(); return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n'); } - -export { assertValidSnippetName }; diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 08efec9a..40684bec 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -483,7 +483,7 @@ export function detectPackageManagerDetails() { }; } -export function detectPackageManager() { +function detectPackageManager() { return detectPackageManagerDetails().packageManager; } @@ -621,7 +621,7 @@ export function getUpdateCommand(pm = detectPackageManager()) { /** * Get current installed version from package.json */ -export function getCurrentVersion() { +function getCurrentVersion() { try { const pkgPath = path.resolve(__dirname, '..', '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); @@ -634,7 +634,7 @@ export function getCurrentVersion() { /** * Fetch latest version from npm registry */ -export async function getLatestVersion() { +async function getLatestVersion() { try { const response = await fetch(NPM_REGISTRY_URL, { headers: { Accept: 'application/json' }, @@ -690,7 +690,7 @@ function compareVersions(left, right) { /** * Fetch changelog notes between versions */ -export async function fetchChangelogNotes(fromVersion, toVersion) { +async function fetchChangelogNotes(fromVersion, toVersion) { try { const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10000), @@ -769,7 +769,7 @@ export async function checkForUpdates(options = {}) { /** * Execute the update (used by CLI) */ -export function executeUpdate(pm = detectPackageManager(), options = {}) { +function executeUpdate(pm = detectPackageManager(), options = {}) { const command = getUpdateCommand(pm); if (!options?.silent) { console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index d7ee5b29..f7e3c4c8 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -557,11 +557,3 @@ export const createProjectConfigRuntime = (deps) => { resolveProjectConfigPath, }; }; - -export { - MAX_TASK_NAME_LENGTH, - MAX_TASK_PROMPT_LENGTH, - MAX_CRON_LENGTH, - MAX_LAST_ERROR_LENGTH, - normalizeTaskForStorage, -}; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index c28aa8e8..ec2edd4a 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -7,7 +7,6 @@ This module fetches quota and usage signals for supported providers in the web s - `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. - `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers. @@ -42,6 +41,9 @@ All providers should return results via shared helpers to preserve API shape: - Optional field: `error` - Unsupported provider requests should return `ok: false`, `configured: false`, `error: Unsupported provider` +Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`. +`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data. + ## Add a new provider (quick steps) 1. Choose module shape based on complexity: - Simple providers: create `packages/web/server/lib/quota/providers/.js`. diff --git a/packages/web/server/lib/quota/providers/claude.js b/packages/web/server/lib/quota/providers/claude.js index 6bba363e..8e83dae6 100644 --- a/packages/web/server/lib/quota/providers/claude.js +++ b/packages/web/server/lib/quota/providers/claude.js @@ -10,7 +10,7 @@ import { export const providerId = 'claude'; export const providerName = 'Claude'; -export const aliases = ['anthropic', 'claude']; +const aliases = ['anthropic', 'claude']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/codex.js b/packages/web/server/lib/quota/providers/codex.js index 3c8d5358..3f12cde0 100644 --- a/packages/web/server/lib/quota/providers/codex.js +++ b/packages/web/server/lib/quota/providers/codex.js @@ -11,7 +11,7 @@ import { export const providerId = 'codex'; export const providerName = 'Codex'; -export const aliases = ['openai', 'codex', 'chatgpt']; +const aliases = ['openai', 'codex', 'chatgpt']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/copilot.js b/packages/web/server/lib/quota/providers/copilot.js index 9df7b3d2..964f2f71 100644 --- a/packages/web/server/lib/quota/providers/copilot.js +++ b/packages/web/server/lib/quota/providers/copilot.js @@ -40,7 +40,7 @@ const buildCopilotWindows = (payload) => { export const providerId = 'github-copilot'; export const providerName = 'GitHub Copilot'; -export const aliases = ['github-copilot', 'copilot']; +const aliases = ['github-copilot', 'copilot']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/cursor.js b/packages/web/server/lib/quota/providers/cursor.js index bcf0899d..2e82bd60 100644 --- a/packages/web/server/lib/quota/providers/cursor.js +++ b/packages/web/server/lib/quota/providers/cursor.js @@ -21,7 +21,7 @@ const STATE_DB = join(homedir(), 'Library', 'Application Support', 'Cursor', 'Us export const providerId = 'cursor'; export const providerName = 'Cursor'; -export const aliases = ['cursor']; +const aliases = ['cursor']; const readJwtPayload = (token) => { try { diff --git a/packages/web/server/lib/quota/providers/google/auth.js b/packages/web/server/lib/quota/providers/google/auth.js index a36aacba..70d10099 100644 --- a/packages/web/server/lib/quota/providers/google/auth.js +++ b/packages/web/server/lib/quota/providers/google/auth.js @@ -39,7 +39,7 @@ export const resolveGoogleOAuthClient = (sourceId) => { }; }; -export const resolveGeminiCliAuth = (auth) => { +const resolveGeminiCliAuth = (auth) => { const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'google.oauth'])); const entryObject = asObject(entry); if (!entryObject) { @@ -64,7 +64,7 @@ export const resolveGeminiCliAuth = (auth) => { }; }; -export const resolveAntigravityAuth = () => { +const resolveAntigravityAuth = () => { for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { const data = readJsonFile(filePath); const accounts = data?.accounts; diff --git a/packages/web/server/lib/quota/providers/google/index.js b/packages/web/server/lib/quota/providers/google/index.js index 0fabc907..e24b3ef7 100644 --- a/packages/web/server/lib/quota/providers/google/index.js +++ b/packages/web/server/lib/quota/providers/google/index.js @@ -1,30 +1,3 @@ -/** - * Google Provider - * - * Google quota provider implementation. - * @module quota/providers/google - */ - -export { - resolveGoogleOAuthClient, - resolveGeminiCliAuth, - resolveAntigravityAuth, - resolveGoogleAuthSources, - DEFAULT_PROJECT_ID -} from './auth.js'; - -export { - resolveGoogleWindow, - transformQuotaBucket, - transformModelData -} from './transforms.js'; - -export { - refreshGoogleAccessToken, - fetchGoogleQuotaBuckets, - fetchGoogleModels -} from './api.js'; - import { buildResult } from '../../utils/index.js'; import { resolveGoogleAuthSources, diff --git a/packages/web/server/lib/quota/providers/google/transforms.js b/packages/web/server/lib/quota/providers/google/transforms.js index 9954588c..f140d192 100644 --- a/packages/web/server/lib/quota/providers/google/transforms.js +++ b/packages/web/server/lib/quota/providers/google/transforms.js @@ -29,7 +29,7 @@ export const parseGoogleRefreshToken = (rawRefreshToken) => { }; }; -export const resolveGoogleWindow = (sourceId, resetAt) => { +const resolveGoogleWindow = (sourceId, resetAt) => { if (sourceId === 'gemini') { return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; } diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 0a72cdc6..0b212d2e 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -168,7 +168,7 @@ export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; export const fetchKimiQuota = kimi.fetchQuota; export const fetchOpenRouterQuota = openrouter.fetchQuota; export const fetchZaiQuota = zai.fetchQuota; -export const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota; +const fetchZhipuaiCodingPlanQuota = zhipuaiCodingPlan.fetchQuota; export const fetchNanoGptQuota = nanogpt.fetchQuota; export const fetchMinimaxCodingPlanQuota = minimaxCodingPlan.fetchQuota; export const fetchMinimaxCnCodingPlanQuota = minimaxCnCodingPlan.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/interface.js b/packages/web/server/lib/quota/providers/interface.js deleted file mode 100644 index 9bf461a7..00000000 --- a/packages/web/server/lib/quota/providers/interface.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Quota Provider Interface - * - * Defines the contract for implementing quota providers. - * @module quota/providers - */ - -/** - * @typedef {Object} UsageWindow - * @property {number|null} usedPercent - Percentage of usage (0-100) - * @property {number|null} remainingPercent - Percentage remaining (0-100) - * @property {number|null} windowSeconds - Window duration in seconds - * @property {number|null} resetAfterSeconds - Seconds until reset - * @property {number|null} resetAt - Unix timestamp when quota resets - * @property {string|null} resetAtFormatted - Human-readable reset time - * @property {string|null} resetAfterFormatted - Human-readable time until reset - * @property {string|null} valueLabel - Optional label for display (e.g., "$10.00 remaining") - */ - -/** - * @typedef {Object} ProviderUsage - * @property {Object.} windows - Usage windows by key (e.g., '5h', '7d', 'daily') - * @property {Object.} [models] - Model-specific usage (provider-specific) - */ - -/** - * @typedef {Object} QuotaProviderResult - * @property {string} providerId - Unique identifier for the provider - * @property {string} providerName - Display name for the provider - * @property {boolean} ok - Whether the fetch was successful - * @property {boolean} configured - Whether the provider is configured - * @property {ProviderUsage|null} usage - Usage data if successful - * @property {string|null} [error] - Error message if not successful - * @property {number} fetchedAt - Unix timestamp when the result was fetched - */ - -/** - * @typedef {Function} ProviderQuotaFetcher - * @returns {Promise} - */ - -/** - * @typedef {Function} ProviderConfigurationChecker - * @param {Object.} [auth] - * @returns {boolean} - */ - -/** - * @typedef {Object} QuotaProvider - * @property {string} providerId - * @property {string} providerName - * @property {string[]} aliases - * @property {ProviderConfigurationChecker} isConfigured - * @property {ProviderQuotaFetcher} fetchQuota - */ diff --git a/packages/web/server/lib/quota/providers/kimi.js b/packages/web/server/lib/quota/providers/kimi.js index cb5f83ba..a9d6c893 100644 --- a/packages/web/server/lib/quota/providers/kimi.js +++ b/packages/web/server/lib/quota/providers/kimi.js @@ -12,7 +12,7 @@ import { export const providerId = 'kimi-for-coding'; export const providerName = 'Kimi for Coding'; -export const aliases = ['kimi-for-coding', 'kimi']; +const aliases = ['kimi-for-coding', 'kimi']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js b/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js index 98e0e1d5..8ccc1363 100644 --- a/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js +++ b/packages/web/server/lib/quota/providers/minimax-cn-coding-plan.js @@ -10,6 +10,6 @@ const provider = createMiniMaxCodingPlanProvider({ export const providerId = provider.providerId; export const providerName = provider.providerName; -export const aliases = provider.aliases; +const aliases = provider.aliases; export const isConfigured = provider.isConfigured; export const fetchQuota = provider.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/minimax-coding-plan.js b/packages/web/server/lib/quota/providers/minimax-coding-plan.js index d800cb26..dce531c1 100644 --- a/packages/web/server/lib/quota/providers/minimax-coding-plan.js +++ b/packages/web/server/lib/quota/providers/minimax-coding-plan.js @@ -10,6 +10,6 @@ const provider = createMiniMaxCodingPlanProvider({ export const providerId = provider.providerId; export const providerName = provider.providerName; -export const aliases = provider.aliases; +const aliases = provider.aliases; export const isConfigured = provider.isConfigured; export const fetchQuota = provider.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/nanogpt.js b/packages/web/server/lib/quota/providers/nanogpt.js index 00875f58..ae9348bc 100644 --- a/packages/web/server/lib/quota/providers/nanogpt.js +++ b/packages/web/server/lib/quota/providers/nanogpt.js @@ -12,7 +12,7 @@ const NANO_GPT_DAILY_WINDOW_SECONDS = 86400; export const providerId = 'nano-gpt'; export const providerName = 'NanoGPT'; -export const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt']; +const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/ollama-cloud.js b/packages/web/server/lib/quota/providers/ollama-cloud.js index 463b7dd7..29808ab1 100644 --- a/packages/web/server/lib/quota/providers/ollama-cloud.js +++ b/packages/web/server/lib/quota/providers/ollama-cloud.js @@ -7,7 +7,7 @@ const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie'); export const providerId = 'ollama-cloud'; export const providerName = 'Ollama Cloud'; -export const aliases = ['ollama-cloud', 'ollamacloud']; +const aliases = ['ollama-cloud', 'ollamacloud']; const readCookieFile = () => { try { diff --git a/packages/web/server/lib/quota/providers/openai.js b/packages/web/server/lib/quota/providers/openai.js index 28fc5c1c..ca01522d 100644 --- a/packages/web/server/lib/quota/providers/openai.js +++ b/packages/web/server/lib/quota/providers/openai.js @@ -8,11 +8,11 @@ import { toTimestamp } from '../utils/index.js'; -export const providerId = 'openai'; -export const providerName = 'OpenAI'; -export const aliases = ['openai', 'codex', 'chatgpt']; +const providerId = 'openai'; +const providerName = 'OpenAI'; +const aliases = ['openai', 'codex', 'chatgpt']; -export const isConfigured = () => { +const isConfigured = () => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); return Boolean(entry?.access || entry?.token); diff --git a/packages/web/server/lib/quota/providers/openrouter.js b/packages/web/server/lib/quota/providers/openrouter.js index cd43770d..f82ed45a 100644 --- a/packages/web/server/lib/quota/providers/openrouter.js +++ b/packages/web/server/lib/quota/providers/openrouter.js @@ -10,7 +10,7 @@ import { export const providerId = 'openrouter'; export const providerName = 'OpenRouter'; -export const aliases = ['openrouter']; +const aliases = ['openrouter']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/wafer.js b/packages/web/server/lib/quota/providers/wafer.js index 399123a5..757aabdd 100644 --- a/packages/web/server/lib/quota/providers/wafer.js +++ b/packages/web/server/lib/quota/providers/wafer.js @@ -12,7 +12,7 @@ import { export const providerId = 'wafer'; export const providerName = 'Wafer.ai'; -export const aliases = ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai']; +const aliases = ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai']; const WAFER_QUOTA_URL = 'https://pass.wafer.ai/v1/inference/quota'; const WAFER_WINDOW_SECONDS = 5 * 3600; diff --git a/packages/web/server/lib/quota/providers/zai.js b/packages/web/server/lib/quota/providers/zai.js index 4eec1350..980c51bd 100644 --- a/packages/web/server/lib/quota/providers/zai.js +++ b/packages/web/server/lib/quota/providers/zai.js @@ -13,7 +13,7 @@ import { export const providerId = 'zai-coding-plan'; export const providerName = 'z.ai'; -export const aliases = ['zai-coding-plan', 'zai', 'z.ai']; +const aliases = ['zai-coding-plan', 'zai', 'z.ai']; export const isConfigured = () => { const auth = readAuthFile(); diff --git a/packages/web/server/lib/quota/providers/zhipuai-coding-plan.js b/packages/web/server/lib/quota/providers/zhipuai-coding-plan.js index fbc84606..247f4adb 100644 --- a/packages/web/server/lib/quota/providers/zhipuai-coding-plan.js +++ b/packages/web/server/lib/quota/providers/zhipuai-coding-plan.js @@ -38,7 +38,7 @@ import { export const providerId = 'zhipuai-coding-plan'; export const providerName = 'Zhipu AI Coding Plan'; -export const aliases = ['zhipuai-coding-plan', 'zhipuai', 'zhipu']; +const aliases = ['zhipuai-coding-plan', 'zhipuai', 'zhipu']; function getApiKey() { const auth = readAuthFile(); diff --git a/packages/web/server/lib/skills-catalog/cache.js b/packages/web/server/lib/skills-catalog/cache.js index 10800acb..3fbbae5e 100644 --- a/packages/web/server/lib/skills-catalog/cache.js +++ b/packages/web/server/lib/skills-catalog/cache.js @@ -23,7 +23,3 @@ export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) { const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS; cache.set(key, { expiresAt: Date.now() + ttl, value }); } - -export function clearCache() { - cache.clear(); -} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/api.js b/packages/web/server/lib/skills-catalog/clawdhub/api.js index ac463b52..b0f23986 100644 --- a/packages/web/server/lib/skills-catalog/clawdhub/api.js +++ b/packages/web/server/lib/skills-catalog/clawdhub/api.js @@ -82,38 +82,6 @@ export async function fetchClawdHubSkills({ cursor } = {}) { }; } -/** - * Fetch details for a specific skill version - * @param {string} slug - Skill slug/identifier - * @param {string} [version='latest'] - Version string or 'latest' - * @returns {Promise<{ skill: Object, version: Object }>} - */ -export async function fetchClawdHubSkillVersion(slug, version = 'latest') { - // For 'latest', we need to first get the skill metadata to find the latest version - if (version === 'latest') { - const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`); - if (!skillResponse.ok) { - throw new Error(`ClawdHub skill not found: ${slug}`); - } - const skillData = await skillResponse.json(); - const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version; - if (!latestVersion) { - throw new Error(`No latest version found for skill: ${slug}`); - } - version = latestVersion; - } - - const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`; - const response = await rateLimitedFetch(url); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`); - } - - return response.json(); -} - /** * Download a skill package as a ZIP buffer * @param {string} slug - Skill slug/identifier diff --git a/packages/web/server/lib/skills-catalog/clawdhub/index.js b/packages/web/server/lib/skills-catalog/clawdhub/index.js deleted file mode 100644 index f81ca2e4..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/index.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * ClawdHub integration module - * - * Provides skill browsing and installation from the ClawdHub registry. - * https://clawdhub.com - */ - -export { scanClawdHub, scanClawdHubPage } from './scan.js'; -export { installSkillsFromClawdHub } from './install.js'; -export { - fetchClawdHubSkills, - fetchClawdHubSkillVersion, - fetchClawdHubSkillInfo, - downloadClawdHubSkill, -} from './api.js'; - -/** - * Check if a source string refers to ClawdHub - * @param {string} source - * @returns {boolean} - */ -export function isClawdHubSource(source) { - return typeof source === 'string' && source.startsWith('clawdhub:'); -} - -/** - * ClawdHub source identifier used in curated sources - */ -export const CLAWDHUB_SOURCE_ID = 'clawdhub'; -export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry'; diff --git a/packages/web/server/lib/skills-catalog/clawdhub/scan.js b/packages/web/server/lib/skills-catalog/clawdhub/scan.js index 2d70dad1..5a8a6e4d 100644 --- a/packages/web/server/lib/skills-catalog/clawdhub/scan.js +++ b/packages/web/server/lib/skills-catalog/clawdhub/scan.js @@ -7,7 +7,6 @@ import { fetchClawdHubSkills } from './api.js'; -const MAX_PAGES = 20; // Safety limit to prevent infinite loops const CLAWDHUB_PAGE_LIMIT = 25; const mapClawdHubItem = (item) => { @@ -39,57 +38,6 @@ const mapClawdHubItem = (item) => { }; }; -/** - * Scan ClawdHub registry for all available skills - * @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>} - */ -export async function scanClawdHub() { - try { - const allItems = []; - let cursor = null; - - for (let page = 0; page < MAX_PAGES; page++) { - let items = []; - let nextCursor = null; - - try { - const pageResult = await fetchClawdHubSkills({ cursor }); - items = pageResult.items || []; - nextCursor = pageResult.nextCursor || null; - } catch (error) { - if (page > 0 && allItems.length > 0) { - console.warn('ClawdHub pagination failed; returning partial results.'); - break; - } - throw error; - } - - for (const item of items) { - allItems.push(mapClawdHubItem(item)); - } - - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - // Sort by downloads (most popular first) - allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); - - return { ok: true, items: allItems }; - } catch (error) { - console.error('ClawdHub scan error:', error); - return { - ok: false, - error: { - kind: 'networkError', - message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', - }, - }; - } -} - /** * Scan a single ClawdHub page (cursor-based) * @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>} diff --git a/packages/web/server/lib/skills-catalog/curated-sources.js b/packages/web/server/lib/skills-catalog/curated-sources.js index 6f6cf9d3..0144199c 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.js @@ -1,4 +1,4 @@ -export const CURATED_SKILLS_SOURCES = [ +const CURATED_SKILLS_SOURCES = [ { id: 'anthropic', label: 'Anthropic', diff --git a/packages/web/server/lib/skills-catalog/index.js b/packages/web/server/lib/skills-catalog/index.js deleted file mode 100644 index 6f5cb1dd..00000000 --- a/packages/web/server/lib/skills-catalog/index.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Skills catalog module - * - * Provides skill scanning, installation, and caching from GitHub repositories and ClawdHub. - */ - -export { - CURATED_SKILLS_SOURCES, - getCuratedSkillsSources, -} from './curated-sources.js'; - -export { - getCacheKey, - getCachedScan, - setCachedScan, - clearCache, -} from './cache.js'; - -export { - parseSkillRepoSource, -} from './source.js'; - -export { - scanSkillsRepository, -} from './scan.js'; - -export { - installSkillsFromRepository, -} from './install.js'; - -export { - scanClawdHub, - scanClawdHubPage, - installSkillsFromClawdHub, - fetchClawdHubSkills, - fetchClawdHubSkillVersion, - fetchClawdHubSkillInfo, - downloadClawdHubSkill, - isClawdHubSource, - CLAWDHUB_SOURCE_ID, - CLAWDHUB_SOURCE_STRING, -} from './clawdhub/index.js'; diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 5af2a100..24e1dd8f 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -1,4 +1,5 @@ const GITHUB_HOST = 'github.com'; +const CLAWDHUB_SOURCE_PREFIX = 'clawdhub:'; function normalizeGitOwnerRepo(owner, repo) { @@ -85,3 +86,7 @@ export function parseSkillRepoSource(input, options = {}) { return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } }; } + +export function isClawdHubSource(input) { + return typeof input === 'string' && input.trim().toLowerCase().startsWith(CLAWDHUB_SOURCE_PREFIX); +} diff --git a/packages/web/server/lib/terminal/index.js b/packages/web/server/lib/terminal/index.js deleted file mode 100644 index d23d9eb2..00000000 --- a/packages/web/server/lib/terminal/index.js +++ /dev/null @@ -1,31 +0,0 @@ -export { - TERMINAL_WS_PATH, - TERMINAL_WS_CONTROL_TAG_JSON, - TERMINAL_WS_MAX_PAYLOAD_BYTES, - isTerminalWsPathname, - parseRequestPathname, - normalizeTerminalWsMessageToBuffer, - normalizeTerminalWsMessageToText, - readTerminalWsControlFrame, - createTerminalWsControlFrame, - pruneRebindTimestamps, - isRebindRateLimited, -} from './terminal-ws-protocol.js'; - -export { - TERMINAL_WS_PATH as TERMINAL_INPUT_WS_PATH, - TERMINAL_WS_CONTROL_TAG_JSON as TERMINAL_INPUT_WS_CONTROL_TAG_JSON, - TERMINAL_WS_MAX_PAYLOAD_BYTES as TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - normalizeTerminalWsMessageToBuffer as normalizeTerminalInputWsMessageToBuffer, - normalizeTerminalWsMessageToText as normalizeTerminalInputWsMessageToText, - readTerminalWsControlFrame as readTerminalInputWsControlFrame, - createTerminalWsControlFrame as createTerminalInputWsControlFrame, -} from './terminal-ws-protocol.js'; - -export { - TERMINAL_OUTPUT_REPLAY_MAX_BYTES, - createTerminalOutputReplayBuffer, - appendTerminalOutputReplayChunk, - listTerminalOutputReplayChunksSince, - getLatestTerminalOutputReplayChunkId, -} from './output-replay-buffer.js'; diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 2399bd74..06962090 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -1,18 +1,20 @@ import { WebSocketServer } from 'ws'; import { - TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - TERMINAL_INPUT_WS_PATH, + TERMINAL_WS_MAX_PAYLOAD_BYTES as TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + TERMINAL_WS_PATH as TERMINAL_INPUT_WS_PATH, + createTerminalWsControlFrame as createTerminalInputWsControlFrame, + isRebindRateLimited, + normalizeTerminalWsMessageToText as normalizeTerminalInputWsMessageToText, + parseRequestPathname, + pruneRebindTimestamps, + readTerminalWsControlFrame as readTerminalInputWsControlFrame, +} from './terminal-ws-protocol.js'; +import { TERMINAL_OUTPUT_REPLAY_MAX_BYTES, appendTerminalOutputReplayChunk, createTerminalOutputReplayBuffer, - createTerminalInputWsControlFrame, - isRebindRateLimited, listTerminalOutputReplayChunksSince, - normalizeTerminalInputWsMessageToText, - parseRequestPathname, - pruneRebindTimestamps, - readTerminalInputWsControlFrame, -} from './index.js'; +} from './output-replay-buffer.js'; export function createTerminalRuntime({ app, diff --git a/packages/web/server/lib/text/summarization.js b/packages/web/server/lib/text/summarization.js index 03a6d571..1848c814 100644 --- a/packages/web/server/lib/text/summarization.js +++ b/packages/web/server/lib/text/summarization.js @@ -25,7 +25,7 @@ export function sanitizeForTTS(text) { .trim(); } -export function sanitizeForNotification(text) { +function sanitizeForNotification(text) { if (!text || typeof text !== 'string') return ''; return text diff --git a/packages/web/server/lib/tunnels/providers/ngrok.js b/packages/web/server/lib/tunnels/providers/ngrok.js index 76d94399..a567a36b 100644 --- a/packages/web/server/lib/tunnels/providers/ngrok.js +++ b/packages/web/server/lib/tunnels/providers/ngrok.js @@ -13,7 +13,7 @@ import { } from '../types.js'; import { getTunnelDependencyInstallInfo } from '../install-help.js'; -export const ngrokTunnelProviderCapabilities = { +const ngrokTunnelProviderCapabilities = { provider: TUNNEL_PROVIDER_NGROK, defaults: { mode: TUNNEL_MODE_QUICK, diff --git a/packages/web/server/lib/tunnels/types.js b/packages/web/server/lib/tunnels/types.js index 585ce779..b0940fc6 100644 --- a/packages/web/server/lib/tunnels/types.js +++ b/packages/web/server/lib/tunnels/types.js @@ -10,7 +10,7 @@ export const TUNNEL_MODE_MANAGED_LOCAL = 'managed-local'; export const TUNNEL_INTENT_EPHEMERAL_PUBLIC = 'ephemeral-public'; export const TUNNEL_INTENT_PERSISTENT_PUBLIC = 'persistent-public'; -export const TUNNEL_INTENT_PRIVATE_NETWORK = 'private-network'; +const TUNNEL_INTENT_PRIVATE_NETWORK = 'private-network'; const SUPPORTED_TUNNEL_INTENTS = new Set([ TUNNEL_INTENT_EPHEMERAL_PUBLIC, @@ -108,7 +108,7 @@ export function normalizeTunnelMode(value) { return TUNNEL_MODE_QUICK; } -export function normalizeTunnelIntent(value) { +function normalizeTunnelIntent(value) { if (typeof value !== 'string') { return undefined; }