From 4523e9c486e64b3c1be40867a04f6a5ec75bf865 Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Sun, 26 Apr 2026 16:24:07 +0300 Subject: [PATCH] perf: reduce re-renders, fix mobile keyboard handling, add chunk load recovery, and improve PATH management (#1028) * fix: exclude file content from reverted prompt text Revert and fork now restore only the user's original prompt, not server-injected file content Uses existing isSyntheticPart helper for type-safe filtering * fix: keep scrollbar visible when hovering over thumb * fix: prevent ESC abort from triggering when terminal is focused * fix: pass directory to permission/question reply calls so approvals actually resolve * fix: default model selection not responding after Base UI migration * fix: prevent modal content from shifting and clipping footer buttons * fix: improve session switching performance and add sub-agent export with prompt collapse Defer viewport anchor saving to eliminate ~800ms UI freeze when switching sessions Add export dialog to include sub-agent tasks recursively in markdown export Add collapse chevron button for expanded user prompts in sticky header * fix: resolve sidebar scroll and TDZ crash in session sidebar * perf: reduce CPU overhead and re-renders across chat, layout, and settings * fix: position collapse button at top of message and prevent ESC abort in terminal * fix: position collapse button at top and add padding only when expanded * refactor: extract shared PATH utilities and mobile keyboard hook * refactor: import shared path-utils in electron, use module-level style constants - Electron now imports pathLooksUserConfigured/mergePathValues from shared path-utils.js instead of inline duplication - ToolPart collapsedCustomStyle moved from useMemo([]) to module const * fix: resolve remaining merge conflicts and type errors - Remove duplicate variable declarations in SessionNodeItem - Remove orphaned export callback body from conflict resolution - Fix HelpDialog description -> descriptionKey (i18n rename) * fix: resolve type-check and lint errors in session-actions.test.ts - Added missing bun:test type declarations (beforeEach, mock, mock.module) - Removed unused State import - Replaced 'as any' casts with proper OpencodeClient and ChildStoreManager types - Added eslint-disable for unused _ parameter in mock function * fix PR 1028 export and PATH edge cases * fix startup retry exhaustion state * remove opencode package lock change * fix sub-session rename cancellation --------- Co-authored-by: Bohdan Triapitsyn --- packages/electron/main.mjs | 16 +- packages/ui/src/App.tsx | 105 ++++++- .../src/components/chat/ChatErrorBoundary.tsx | 2 +- packages/ui/src/components/chat/ChatInput.tsx | 21 +- .../components/chat/CommandAutocomplete.tsx | 6 +- .../ui/src/components/chat/DiffPreview.tsx | 51 ++-- .../ui/src/components/chat/FileAttachment.tsx | 8 +- .../ui/src/components/chat/MessageList.tsx | 18 +- .../chat/MobileSessionStatusBar.tsx | 3 +- .../ui/src/components/chat/ModelControls.tsx | 30 +- .../ui/src/components/chat/PermissionCard.tsx | 75 ++--- .../src/components/chat/PermissionRequest.tsx | 6 +- .../src/components/chat/SkillAutocomplete.tsx | 3 +- .../ui/src/components/chat/StatusChip.tsx | 12 +- packages/ui/src/components/chat/StatusRow.tsx | 4 +- .../chat/hooks/useChatTimelineController.ts | 44 +-- .../chat/hooks/useStreamingTextThrottle.ts | 10 +- .../components/chat/message/MessageBody.tsx | 34 +-- .../chat/message/TextSelectionMenu.tsx | 16 +- .../chat/message/ToolOutputDialog.tsx | 19 +- .../chat/message/parts/ProgressiveGroup.tsx | 12 +- .../chat/message/parts/ReasoningPart.tsx | 8 +- .../chat/message/parts/ToolPart.tsx | 73 +++-- .../chat/message/parts/UserTextPart.tsx | 40 +-- .../chat/message/parts/WorkingPlaceholder.tsx | 10 +- .../components/chat/message/toolRenderers.tsx | 19 +- .../ui/src/components/layout/MainLayout.tsx | 250 +---------------- packages/ui/src/components/layout/Sidebar.tsx | 2 +- .../components/layout/SidebarFilesTree.tsx | 7 +- .../components/multirun/MultiRunLauncher.tsx | 2 +- .../components/sections/agents/AgentsPage.tsx | 19 +- .../sections/agents/AgentsSidebar.tsx | 11 +- .../sections/agents/ModelSelector.tsx | 22 +- .../sections/commands/CommandsPage.tsx | 19 +- .../sections/commands/CommandsSidebar.tsx | 14 +- .../GitIdentityEditorDialog.tsx | 10 +- .../sections/git-identities/GitPage.tsx | 14 +- .../src/components/sections/mcp/McpPage.tsx | 13 +- .../components/sections/mcp/McpSidebar.tsx | 10 +- .../sections/openchamber/AboutSettings.tsx | 15 +- .../sections/shared/SettingsSidebarItem.tsx | 4 +- .../components/sections/skills/SkillsPage.tsx | 27 +- .../sections/skills/SkillsSidebar.tsx | 11 +- .../skills/catalog/AddCatalogDialog.tsx | 4 +- .../skills/catalog/InstallFromRepoDialog.tsx | 5 +- .../skills/catalog/InstallSkillDialog.tsx | 3 +- .../skills/catalog/SkillsCatalogPage.tsx | 17 +- .../session/DirectoryExplorerDialog.tsx | 7 +- .../src/components/session/SessionDialogs.tsx | 8 +- .../src/components/session/SessionSidebar.tsx | 14 +- .../session/sidebar/SessionGroupSection.tsx | 38 +-- .../session/sidebar/SessionNodeItem.tsx | 133 ++++++++- .../ui/src/components/ui/ErrorBoundary.tsx | 2 +- packages/ui/src/components/ui/HelpDialog.tsx | 4 +- .../ui/src/components/ui/OverlayScrollbar.tsx | 24 ++ packages/ui/src/components/ui/dialog.tsx | 2 +- .../ui/src/components/views/FilesView.tsx | 7 +- packages/ui/src/components/views/GitView.tsx | 10 +- .../ui/src/components/views/TerminalView.tsx | 21 +- .../agent-manager/AgentManagerEmptyState.tsx | 13 +- .../components/views/git/AIHighlightsBox.tsx | 2 +- .../components/views/git/ConflictDialog.tsx | 4 +- .../components/views/git/HistoryCommitRow.tsx | 6 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 6 +- packages/ui/src/hooks/useMenuActions.ts | 2 +- .../ui/src/hooks/useMobileKeyboardManager.ts | 262 ++++++++++++++++++ packages/ui/src/lib/desktopBoot.test.ts | 8 +- packages/ui/src/lib/exportSession.ts | 38 ++- packages/ui/src/lib/i18n/messages/en.ts | 12 + packages/ui/src/lib/i18n/messages/es.ts | 12 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 12 + packages/ui/src/lib/i18n/messages/uk.ts | 12 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 12 + .../ui/src/lib/messages/synthetic.test.ts | 123 ++++++++ .../stores/utils/permissionAutoAccept.test.ts | 98 +++++++ packages/ui/src/sync/session-actions.test.ts | 241 ++++++++++++++++ packages/ui/src/sync/session-actions.ts | 27 +- packages/ui/src/sync/session-ui-store.ts | 25 +- .../ui/src/sync/session-worktree-contract.ts | 10 +- packages/ui/src/types/bun-test.d.ts | 5 + packages/web/index.html | 2 + .../web/server/lib/opencode/env-runtime.js | 21 +- .../web/server/lib/opencode/lifecycle.test.js | 47 ++++ .../web/server/lib/opencode/path-utils.js | 100 +++++++ .../server/lib/opencode/path-utils.test.js | 71 +++++ .../lib/opencode/server-utils-runtime.js | 46 +-- .../lib/opencode/server-utils-runtime.test.js | 10 +- 87 files changed, 1918 insertions(+), 703 deletions(-) create mode 100644 packages/ui/src/hooks/useMobileKeyboardManager.ts create mode 100644 packages/ui/src/lib/messages/synthetic.test.ts create mode 100644 packages/ui/src/stores/utils/permissionAutoAccept.test.ts create mode 100644 packages/ui/src/sync/session-actions.test.ts create mode 100644 packages/web/server/lib/opencode/path-utils.js create mode 100644 packages/web/server/lib/opencode/path-utils.test.js diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 9cff1514..15b8aaff 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -707,6 +707,8 @@ const loadShellEnv = () => { }; // Merge the user's login-shell env (PATH, etc.) into this process before we +import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js'; + // import/start the server in-process. The server and its children (opencode // CLI, git, etc.) inherit process.env directly now — there is no sidecar // subprocess to hand a custom env to. @@ -716,13 +718,7 @@ const inheritUserShellEnv = () => { const homeDir = os.homedir(); const currentPath = process.env.PATH || ''; - const currentPathLooksUserConfigured = currentPath.split(':').some((segment) => ( - segment.startsWith(`${homeDir}${path.sep}`) - || segment === homeDir - || segment.startsWith('/opt/homebrew/') - || segment.startsWith('/opt/pkg/') - || segment.startsWith('/opt/pmk/') - )); + const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':'); for (const [key, value] of Object.entries(shellEnv)) { if (key === 'PATH') continue; @@ -730,8 +726,10 @@ const inheritUserShellEnv = () => { process.env[key] = value; } } - if (!currentPathLooksUserConfigured && typeof shellEnv.PATH === 'string' && shellEnv.PATH.length > 0) { - process.env.PATH = shellEnv.PATH; + + const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : ''; + if (!currentPathLooksUserConfigured && shellPath) { + process.env.PATH = mergePathValues(shellPath, currentPath, ':'); } }; diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 388d0b88..967223a6 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,6 +5,7 @@ import { AgentManagerView } from '@/components/views/agent-manager'; import { ChatView } from '@/components/views'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; +import { Button } from '@/components/ui/button'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; @@ -56,6 +57,7 @@ import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog'; import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage'; import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; +import { useI18n } from '@/lib/i18n'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. const OnboardingScreen = lazyWithChunkRecovery(() => @@ -73,6 +75,27 @@ const AboutDialogWrapper: React.FC = () => { ); }; +const StartupInitializationRecovery: React.FC<{ + onRetry: () => void; + isRetrying: boolean; +}> = ({ onRetry, isRetrying }) => { + const { t } = useI18n(); + + return ( +
+
+
+

{t('startup.initRecovery.title')}

+

{t('startup.initRecovery.description')}

+
+ +
+
+ ); +}; + type AppProps = { apis: RuntimeAPIs; }; @@ -195,6 +218,9 @@ function App({ apis }: AppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true); + const [initRetryExhausted, setInitRetryExhausted] = React.useState(false); + const [initRetryEpoch, setInitRetryEpoch] = React.useState(0); + const [manualInitRetrying, setManualInitRetrying] = React.useState(false); const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const [bootInjectionStatus, setBootInjectionStatus] = React.useState(() => { @@ -366,16 +392,27 @@ function App({ apis }: AppProps) { let active = true; let retryTimer: ReturnType | undefined; + let retryCount = 0; + const MAX_RETRIES = 10; + const BASE_DELAY_MS = 1000; const retryInitialization = async () => { if (!active) return; + if (retryCount >= MAX_RETRIES) { + setInitRetryExhausted(true); + return; + } const state = useConfigStore.getState(); - if (state.isInitialized) return; + if (state.isInitialized) { + setInitRetryExhausted(false); + return; + } if (initializationInFlightRef.current) { - retryTimer = setTimeout(retryInitialization, 1000); + retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS); return; } + retryCount += 1; initializationInFlightRef.current = true; try { await state.initializeApp(); @@ -384,17 +421,44 @@ function App({ apis }: AppProps) { } const next = useConfigStore.getState(); - if (!active || next.isInitialized) return; - retryTimer = setTimeout(retryInitialization, 1000); + if (!active) return; + if (next.isInitialized) { + setInitRetryExhausted(false); + return; + } + if (retryCount >= MAX_RETRIES) { + setInitRetryExhausted(true); + return; + } + const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount - 1), 16000); + retryTimer = setTimeout(retryInitialization, delay); }; - retryTimer = setTimeout(retryInitialization, 1000); + retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS); return () => { active = false; if (retryTimer) clearTimeout(retryTimer); }; - }, [isInitialized, isVSCodeRuntime]); + }, [initRetryEpoch, isInitialized, isVSCodeRuntime]); + + React.useEffect(() => { + if (isInitialized) { + setInitRetryExhausted(false); + } + }, [isInitialized]); + + React.useEffect(() => { + if (!initRetryExhausted) return; + + const loadingElement = document.getElementById('initial-loading'); + if (loadingElement) { + loadingElement.classList.add('fade-out'); + setTimeout(() => { + loadingElement.remove(); + }, 300); + } + }, [initRetryExhausted]); // Startup recovery: poll until providers AND agents are loaded. // loadProviders/loadAgents resolve normally even on failure (errors swallowed), @@ -679,6 +743,24 @@ function App({ apis }: AppProps) { window.location.reload(); }, []); + const handleManualInitRetry = React.useCallback(async () => { + if (manualInitRetrying || initializationInFlightRef.current) return; + + setInitRetryExhausted(false); + setManualInitRetrying(true); + initializationInFlightRef.current = true; + try { + await useConfigStore.getState().initializeApp(); + } finally { + initializationInFlightRef.current = false; + setManualInitRetrying(false); + } + + if (!useConfigStore.getState().isInitialized) { + setInitRetryEpoch((value) => value + 1); + } + }, [manualInitRetrying]); + // Map boot outcome kind to recovery variant const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => { if (view.screen === 'recovery') { @@ -758,6 +840,17 @@ function App({ apis }: AppProps) { ); } + if (initRetryExhausted && !isInitialized && !isVSCodeRuntime && !embeddedSessionChat) { + return ( + + { void handleManualInitRetry(); }} + isRetrying={manualInitRetrying} + /> + + ); + } + // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { // Check if this is the Agent Manager panel diff --git a/packages/ui/src/components/chat/ChatErrorBoundary.tsx b/packages/ui/src/components/chat/ChatErrorBoundary.tsx index 571884b9..29d3e120 100644 --- a/packages/ui/src/components/chat/ChatErrorBoundary.tsx +++ b/packages/ui/src/components/chat/ChatErrorBoundary.tsx @@ -75,7 +75,7 @@ class ChatErrorBoundaryView extends React.Component {this.props.texts.detailsSummary} -
+                  
                     {this.state.error.toString()}
                   
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cefd2f2b..80de5dd6 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -814,11 +814,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const sendableAttachedFiles = attachedFiles; + const knownAgentNames = React.useMemo( + () => new Set(agents.map((agent) => agent.name.toLowerCase())), + [agents] + ); + const knownAgentNamesRef = React.useRef(knownAgentNames); + knownAgentNamesRef.current = knownAgentNames; + const hasInlineMentionForHighlight = React.useMemo(() => { if (!message || !message.includes('@') || inputMode === 'shell') { return false; } - const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const mentionRegex = /@([^\s]+)/g; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(message)) !== null) { @@ -839,7 +845,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } return false; - }, [agents, inputMode, message]); + }, [inputMode, message, knownAgentNames]); const highlightedComposerContent = React.useMemo(() => { if (!hasInlineMentionForHighlight) { @@ -847,7 +853,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = []; - const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const mentionRegex = /@([^\s]+)/g; let lastIndex = 0; let match: RegExpExecArray | null; @@ -880,7 +885,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } return parts; - }, [agents, hasInlineMentionForHighlight, message]); + }, [hasInlineMentionForHighlight, message, knownAgentNames]); const sanitizeAttachmentsForSend = React.useCallback( (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) @@ -900,7 +905,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const clientDirectory = opencodeClient.getDirectory() || ''; const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, ''); - const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const seenPaths = new Set(); const attachments: AttachedFile[] = []; @@ -923,7 +927,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo continue; } - if (knownAgentNames.has(mentionPath.toLowerCase())) { + if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) { continue; } @@ -970,7 +974,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo sanitizedText: rawText, attachments, }; - }, [agents, chatSearchDirectory]); + }, [chatSearchDirectory]); const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); @@ -1670,7 +1674,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const selectionStart = textarea?.selectionStart ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length; const hasCollapsedSelection = selectionStart === selectionEnd; - const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); if (hasCollapsedSelection) { const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; @@ -1688,7 +1691,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const token = message.slice(tokenStart, tokenEnd); const mentionContent = token.slice(1); const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) - && !knownAgentNames.has(mentionContent.toLowerCase()) + && !knownAgentNamesRef.current.has(mentionContent.toLowerCase()) && isConfirmedFilePath(mentionContent); if (looksLikeFileMention) { diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 52d20ee4..12a6bbf4 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -56,8 +56,10 @@ export const CommandAutocomplete = React.forwardRef([]); const [loading, setLoading] = React.useState(false); - const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore(); - const { skills, loadSkills: refreshSkills } = useSkillsStore(); + const commandsWithMetadata = useCommandsStore((s) => s.commands); + const refreshCommands = useCommandsStore((s) => s.loadCommands); + const skills = useSkillsStore((s) => s.skills); + const refreshSkills = useSkillsStore((s) => s.loadSkills); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index 7c144f63..4700fba9 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -4,6 +4,23 @@ import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { parseDiffToUnified } from './message/toolRenderers'; +const DIFF_CUSTOM_STYLE: React.CSSProperties = { + margin: 0, + padding: 0, + fontSize: 'inherit', + background: 'transparent', + backgroundColor: 'transparent', + borderRadius: 0, + overflow: 'visible', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + overflowWrap: 'anywhere', +}; + +const DIFF_CODE_TAG_PROPS = { + style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties, +}; + interface DiffPreviewProps { diff: string; syntaxTheme: { [key: string]: React.CSSProperties }; @@ -46,21 +63,8 @@ export const DiffPreview: React.FC = ({ diff, syntaxTheme, fil PreTag="div" wrapLines wrapLongLines - customStyle={{ - margin: 0, - padding: 0, - fontSize: 'inherit', - background: 'transparent', - backgroundColor: 'transparent', - borderRadius: 0, - overflow: 'visible', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'anywhere', - }} - codeTagProps={{ - style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' }, - }} + customStyle={DIFF_CUSTOM_STYLE} + codeTagProps={DIFF_CODE_TAG_PROPS} > {line.content} @@ -104,21 +108,8 @@ export const WritePreview: React.FC = ({ content, syntaxTheme PreTag="div" wrapLines wrapLongLines - customStyle={{ - margin: 0, - padding: 0, - fontSize: 'inherit', - background: 'transparent', - backgroundColor: 'transparent', - borderRadius: 0, - overflow: 'visible', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'anywhere', - }} - codeTagProps={{ - style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' }, - }} + customStyle={DIFF_CUSTOM_STYLE} + codeTagProps={DIFF_CODE_TAG_PROPS} > {line || ' '} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index d874c2e0..1725bf96 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -522,7 +522,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } if (isImage && file.url) { return (
+ + )}
(null); const [displayedPermission, setDisplayedPermission] = React.useState(false); + const displayedTextRef = React.useRef(displayedText); + const displayedPermissionRef = React.useRef(displayedPermission); + displayedTextRef.current = displayedText; + displayedPermissionRef.current = displayedPermission; const statusShownAtRef = React.useRef(0); const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null); @@ -141,12 +145,12 @@ export function WorkingPlaceholder({ return; } - if (!displayedText) { + if (!displayedTextRef.current) { showStatus(incomingText, incomingPermission); return; } - if (incomingText === displayedText && incomingPermission === displayedPermission) { + if (incomingText === displayedTextRef.current && incomingPermission === displayedPermissionRef.current) { return; } @@ -169,8 +173,6 @@ export function WorkingPlaceholder({ isGenericStatus, isWaitingForPermission, retryInfo, - displayedText, - displayedPermission, clearTimers, showStatus, scheduleQueueProcess, diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index c47fff3c..8344cd88 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -243,6 +243,8 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean } }; +const GREP_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 }; + export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => { try { const lines = output.trim().split('\n').filter(Boolean); @@ -287,7 +289,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: { } return (
-
+
{match.lineNum && ( @@ -312,6 +314,8 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: { } }; +const GLOB_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 }; + export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => { try { const paths = output.trim().split('\n').filter(Boolean); @@ -350,7 +354,7 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: {
{groups[dir].sort().map((filename) => (
-
+
{filename}
))} @@ -388,12 +392,11 @@ export const renderTodoOutput = ( return null; } - const todosByStatus = { - in_progress: todos.filter((t) => t.status === 'in_progress'), - pending: todos.filter((t) => t.status === 'pending'), - completed: todos.filter((t) => t.status === 'completed'), - cancelled: todos.filter((t) => t.status === 'cancelled'), - }; + const todosByStatus = todos.reduce((acc, t) => { + const status = t.status as keyof typeof acc; + if (status in acc) acc[status].push(t); + return acc; + }, { in_progress: [] as Todo[], pending: [] as Todo[], completed: [] as Todo[], cancelled: [] as Todo[] }); const getPriorityDot = (priority?: string) => { const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1'; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index cfebc4cb..750f4bbb 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useMobileKeyboardManager } from '@/hooks/useMobileKeyboardManager'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; @@ -353,254 +354,7 @@ export const MainLayout: React.FC = () => { }; }, []); - React.useEffect(() => { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - const root = document.documentElement; - - let stickyKeyboardInset = 0; - let ignoreOpenUntilZero = false; - let previousHeight = 0; - let maxObservedLayoutHeight = 0; - let previousOrientation = ''; - let keyboardAvoidTarget: HTMLElement | null = null; - - const setKeyboardOpen = useUIStore.getState().setKeyboardOpen; - const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent; - const isAndroid = /Android/i.test(userAgent); - const isIOS = /iPad|iPhone|iPod/.test(userAgent); - - const clearKeyboardAvoidTarget = () => { - if (!keyboardAvoidTarget) { - return; - } - keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px'); - keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active'); - keyboardAvoidTarget = null; - }; - - const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => { - if (!active) { - return null; - } - const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id'); - if (explicitTargetId) { - const explicitTarget = document.getElementById(explicitTargetId); - if (explicitTarget instanceof HTMLElement) { - return explicitTarget; - } - } - const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null; - if (markedTarget) { - // data-keyboard-avoid="none" opts out of translateY avoidance entirely. - // Used by components with their own scroll (e.g. CodeMirror). - if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') { - return null; - } - return markedTarget; - } - if (active.classList.contains('overlay-scrollbar-container')) { - const parent = active.parentElement; - if (parent instanceof HTMLElement) { - return parent; - } - } - return active; - }; - - const forceKeyboardClosed = () => { - stickyKeyboardInset = 0; - ignoreOpenUntilZero = true; - root.style.setProperty('--oc-keyboard-inset', '0px'); - setKeyboardOpen(false); - }; - - let rafId = 0; - - const updateVisualViewport = () => { - const viewport = window.visualViewport; - - const height = viewport ? Math.round(viewport.height) : window.innerHeight; - const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0; - const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait'; - - root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`); - root.style.setProperty('--oc-visual-viewport-height', `${height}px`); - - const active = document.activeElement as HTMLElement | null; - const tagName = active?.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - const isTextTarget = isInput || Boolean(active?.isContentEditable); - - const layoutHeight = Math.round(root.clientHeight || window.innerHeight); - if (previousOrientation !== orientation) { - previousOrientation = orientation; - maxObservedLayoutHeight = layoutHeight; - } else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) { - maxObservedLayoutHeight = layoutHeight; - } - const viewportSum = height + offsetTop; - const rawInset = Math.max(0, layoutHeight - viewportSum); - const rawAndroidResizeInset = isAndroid - ? Math.max(0, maxObservedLayoutHeight - layoutHeight) - : 0; - - const openThreshold = isTextTarget ? 120 : 180; - const measuredInset = rawInset >= openThreshold ? rawInset : 0; - const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold - ? rawAndroidResizeInset - : 0; - const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset); - - if (ignoreOpenUntilZero) { - if (effectiveMeasuredInset === 0) { - ignoreOpenUntilZero = false; - } - stickyKeyboardInset = 0; - } else if (stickyKeyboardInset === 0) { - if (effectiveMeasuredInset > 0 && isTextTarget) { - stickyKeyboardInset = effectiveMeasuredInset; - setKeyboardOpen(true); - } - } else { - const closingByHeight = !isTextTarget && height > previousHeight + 6; - - if (effectiveMeasuredInset === 0) { - stickyKeyboardInset = 0; - setKeyboardOpen(false); - } else if (closingByHeight) { - forceKeyboardClosed(); - } else if (effectiveMeasuredInset > 0 && isTextTarget) { - stickyKeyboardInset = effectiveMeasuredInset; - setKeyboardOpen(true); - } else if (effectiveMeasuredInset > stickyKeyboardInset) { - stickyKeyboardInset = effectiveMeasuredInset; - setKeyboardOpen(true); - } - } - - root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`); - previousHeight = height; - - const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0; - root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`); - - const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null; - - if (!isMobile || !avoidTarget || !active) { - clearKeyboardAvoidTarget(); - } else { - if (avoidTarget !== keyboardAvoidTarget) { - clearKeyboardAvoidTarget(); - keyboardAvoidTarget = avoidTarget; - } - const viewportBottom = offsetTop + height; - const rect = active.getBoundingClientRect(); - const overlap = rect.bottom - viewportBottom; - const clearance = 8; - const keyboardInset = Math.max(stickyKeyboardInset, effectiveMeasuredInset); - const avoidOffset = overlap > clearance && keyboardInset > 0 - ? Math.min(overlap, keyboardInset) - : 0; - const target = keyboardAvoidTarget; - if (target) { - target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`); - target.setAttribute('data-keyboard-avoid-active', 'true'); - } - } - - if (isMobile && isTextTarget) { - const scroller = document.scrollingElement; - if (scroller && scroller.scrollTop !== 0) { - scroller.scrollTop = 0; - } - if (window.scrollY !== 0) { - window.scrollTo(0, 0); - } - } - }; - - const scheduleVisualViewportUpdate = () => { - if (rafId) return; - rafId = requestAnimationFrame(() => { - rafId = 0; - updateVisualViewport(); - }); - }; - - updateVisualViewport(); - - const viewport = window.visualViewport; - viewport?.addEventListener('resize', scheduleVisualViewportUpdate); - viewport?.addEventListener('scroll', scheduleVisualViewportUpdate); - window.addEventListener('resize', scheduleVisualViewportUpdate); - window.addEventListener('orientationchange', scheduleVisualViewportUpdate); - const isTextInputTarget = (element: HTMLElement | null) => { - if (!element) { - return false; - } - const tagName = element.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - return isInput || element.isContentEditable; - }; - - const handleFocusIn = (event: FocusEvent) => { - const target = event.target as HTMLElement | null; - if (isTextInputTarget(target)) { - ignoreOpenUntilZero = false; - } - scheduleVisualViewportUpdate(); - }; - document.addEventListener('focusin', handleFocusIn, true); - - const handleFocusOut = (event: FocusEvent) => { - const target = event.target as HTMLElement | null; - if (!isTextInputTarget(target)) { - return; - } - - const related = event.relatedTarget as HTMLElement | null; - if (isTextInputTarget(related)) { - return; - } - - window.requestAnimationFrame(() => { - if (isTextInputTarget(document.activeElement as HTMLElement | null)) { - return; - } - - const currentViewport = window.visualViewport; - const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight; - const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0; - const layoutHeight = Math.round(root.clientHeight || window.innerHeight); - const viewportSum = height + offsetTop; - const rawInset = Math.max(0, layoutHeight - viewportSum); - - if (rawInset > 0) { - updateVisualViewport(); - return; - } - - forceKeyboardClosed(); - updateVisualViewport(); - }); - }; - - document.addEventListener('focusout', handleFocusOut, true); - - return () => { - if (rafId) cancelAnimationFrame(rafId); - viewport?.removeEventListener('resize', scheduleVisualViewportUpdate); - viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate); - window.removeEventListener('resize', scheduleVisualViewportUpdate); - window.removeEventListener('orientationchange', scheduleVisualViewportUpdate); - document.removeEventListener('focusin', handleFocusIn, true); - document.removeEventListener('focusout', handleFocusOut, true); - clearKeyboardAvoidTarget(); - }; - }, [isMobile]); + useMobileKeyboardManager(isMobile); const secondaryView = React.useMemo(() => { switch (activeMainTab) { diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 002369fd..c12636c8 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -157,7 +157,7 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }} aria-hidden={!isOpen} > -
+
{children}
diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 9ee781a3..8803119c 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -374,6 +374,11 @@ export const SidebarFilesTree: React.FC = () => { const canDelete = Boolean(files.delete); const canReveal = Boolean(files.revealPath); + const fileRowPermissions = React.useMemo( + () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), + [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] + ); + const handleRevealPath = React.useCallback((targetPath: string) => { if (!files.revealPath) return; void files.revealPath(targetPath).catch(() => { @@ -787,7 +792,7 @@ export const SidebarFilesTree: React.FC = () => { isActive={isActive} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} - permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }} + permissions={fileRowPermissions} downloadFile={files.downloadFile} contextMenuPath={contextMenuPath} setContextMenuPath={setContextMenuPath} diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index c5afbc33..ba1eccb7 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -743,7 +743,7 @@ export const MultiRunLauncher: React.FC = ({ ) : ( <> {setupCommands.map((command, index) => ( -
+
{ diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 41a1dc63..634e2d69 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -5,6 +5,7 @@ import { NumberInput } from '@/components/ui/number-input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; +import { useShallow } from 'zustand/react/shallow'; import { useDirectorySync } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; @@ -184,7 +185,23 @@ const buildPermissionConfigWithGlobal = ( export const AgentsPage: React.FC = () => { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); - const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); + const { + selectedAgentName, + getAgentByName, + createAgent, + updateAgent, + agents, + agentDraft, + setAgentDraft, + } = useAgentsStore(useShallow((s) => ({ + selectedAgentName: s.selectedAgentName, + getAgentByName: s.getAgentByName, + createAgent: s.createAgent, + updateAgent: s.updateAgent, + agents: s.agents, + agentDraft: s.agentDraft, + setAgentDraft: s.setAgentDraft, + }))); const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent); diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 25244446..7517ac92 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/dropdown-menu'; import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react'; import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore'; +import { useShallow } from 'zustand/react/shallow'; import { cn } from '@/lib/utils'; import type { Agent } from '@opencode-ai/sdk/v2'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -116,7 +117,15 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => createAgent, deleteAgent, loadAgents, - } = useAgentsStore(); + } = useAgentsStore(useShallow((s) => ({ + selectedAgentName: s.selectedAgentName, + agents: s.agents, + setSelectedAgent: s.setSelectedAgent, + setAgentDraft: s.setAgentDraft, + createAgent: s.createAgent, + deleteAgent: s.deleteAgent, + loadAgents: s.loadAgents, + }))); React.useEffect(() => { loadAgents(); diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index 183da4da..fa59279d 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, @@ -73,7 +74,7 @@ export const ModelSelector: React.FC = ({ const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); - const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const itemRefs = React.useRef<(HTMLElement | null)[]>([]); const allowedProviderSet = React.useMemo(() => { if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) { @@ -176,14 +177,14 @@ export const ModelSelector: React.FC = ({ const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent'; return ( -
{ itemRefs.current[flatIndex] = el; }} className={cn( - "typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer", - isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50" + "group flex items-center gap-2", + isHighlighted && "bg-interactive-selection" )} - onClick={() => handleProviderAndModelChange(provID, modID)} + onSelect={() => handleProviderAndModelChange(provID, modID)} onMouseEnter={() => setSelectedIndex(flatIndex)} >
@@ -223,7 +224,7 @@ export const ModelSelector: React.FC = ({ )}
-
+ ); }; @@ -613,19 +614,18 @@ export const ModelSelector: React.FC = ({
{/* Not selected option */} -
handleProviderAndModelChange('', '')} + onSelect={() => handleProviderAndModelChange('', '')} > {placeholder || t('settings.agents.modelSelector.notSelected')} {!providerId && !modelId && ( )} -
+ diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index ea0c3073..dfa7475d 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore'; +import { useShallow } from 'zustand/react/shallow'; import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react'; import { ModelSelector } from '../agents/ModelSelector'; import { AgentSelector } from './AgentSelector'; @@ -19,7 +20,23 @@ import { useI18n } from '@/lib/i18n'; export const CommandsPage: React.FC = () => { const { t } = useI18n(); - const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore(); + const { + selectedCommandName, + getCommandByName, + createCommand, + updateCommand, + commands, + commandDraft, + setCommandDraft, + } = useCommandsStore(useShallow((s) => ({ + selectedCommandName: s.selectedCommandName, + getCommandByName: s.getCommandByName, + createCommand: s.createCommand, + updateCommand: s.updateCommand, + commands: s.commands, + commandDraft: s.commandDraft, + setCommandDraft: s.setCommandDraft, + }))); const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null; const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand); diff --git a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx index a47b695a..f6860de4 100644 --- a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx +++ b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx @@ -20,6 +20,7 @@ import { import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react'; import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { cn } from '@/lib/utils'; import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector'; @@ -46,8 +47,17 @@ export const CommandsSidebar: React.FC = ({ onItemSelect } createCommand, deleteCommand, loadCommands, - } = useCommandsStore(); - const { skills, loadSkills } = useSkillsStore(); + } = useCommandsStore(useShallow((s) => ({ + selectedCommandName: s.selectedCommandName, + commands: s.commands, + setSelectedCommand: s.setSelectedCommand, + setCommandDraft: s.setCommandDraft, + createCommand: s.createCommand, + deleteCommand: s.deleteCommand, + loadCommands: s.loadCommands, + }))); + const skills = useSkillsStore((s) => s.skills); + const loadSkills = useSkillsStore((s) => s.loadSkills); React.useEffect(() => { loadCommands(); diff --git a/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx b/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx index 5a66fdcc..8a777d47 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentityEditorDialog.tsx @@ -58,12 +58,10 @@ export const GitIdentityEditorDialog: React.FC = ( importData, }) => { const { t } = useI18n(); - const { - getProfileById, - createProfile, - updateProfile, - deleteProfile, - } = useGitIdentitiesStore(); + const getProfileById = useGitIdentitiesStore((s) => s.getProfileById); + const createProfile = useGitIdentitiesStore((s) => s.createProfile); + const updateProfile = useGitIdentitiesStore((s) => s.updateProfile); + const deleteProfile = useGitIdentitiesStore((s) => s.deleteProfile); const selectedProfile = React.useMemo(() => profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null, diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx index 68297e13..5c771ee8 100644 --- a/packages/ui/src/components/sections/git-identities/GitPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx @@ -29,6 +29,7 @@ import { RiShieldKeyholeLine, } from '@remixicon/react'; import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore'; +import { useShallow } from 'zustand/react/shallow'; import { GitSettings } from '@/components/sections/openchamber/GitSettings'; import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings'; import { GitIdentityEditorDialog } from './GitIdentityEditorDialog'; @@ -66,7 +67,18 @@ export const GitPage: React.FC = () => { loadDefaultGitIdentityId, setDefaultGitIdentityId, getUnimportedCredentials, - } = useGitIdentitiesStore(); + } = useGitIdentitiesStore(useShallow((s) => ({ + profiles: s.profiles, + globalIdentity: s.globalIdentity, + defaultGitIdentityId: s.defaultGitIdentityId, + deleteProfile: s.deleteProfile, + loadProfiles: s.loadProfiles, + loadGlobalIdentity: s.loadGlobalIdentity, + loadDiscoveredCredentials: s.loadDiscoveredCredentials, + loadDefaultGitIdentityId: s.loadDefaultGitIdentityId, + setDefaultGitIdentityId: s.setDefaultGitIdentityId, + getUnimportedCredentials: s.getUnimportedCredentials, + }))); const [editorOpen, setEditorOpen] = React.useState(false); const [editorProfileId, setEditorProfileId] = React.useState(null); diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 151302ec..a273d68a 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -13,6 +13,7 @@ import { type McpDraft, type McpScope, } from '@/stores/useMcpConfigStore'; +import { useShallow } from 'zustand/react/shallow'; import { parseImportedMcpSnippet, applyImportedMcpToDraft, @@ -607,7 +608,17 @@ export const McpPage: React.FC = () => { createMcp, updateMcp, deleteMcp, - } = useMcpConfigStore(); + } = useMcpConfigStore(useShallow((s) => ({ + selectedMcpName: s.selectedMcpName, + mcpServers: s.mcpServers, + mcpDraft: s.mcpDraft, + setMcpDraft: s.setMcpDraft, + setSelectedMcp: s.setSelectedMcp, + getMcpByName: s.getMcpByName, + createMcp: s.createMcp, + updateMcp: s.updateMcp, + deleteMcp: s.deleteMcp, + }))); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []); diff --git a/packages/ui/src/components/sections/mcp/McpSidebar.tsx b/packages/ui/src/components/sections/mcp/McpSidebar.tsx index 812d2571..4c554872 100644 --- a/packages/ui/src/components/sections/mcp/McpSidebar.tsx +++ b/packages/ui/src/components/sections/mcp/McpSidebar.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dialog'; import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react'; import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore'; +import { useShallow } from 'zustand/react/shallow'; import { useMcpStore } from '@/stores/useMcpStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { isMobileDeviceViaCSS } from '@/lib/device'; @@ -64,7 +65,14 @@ export const McpSidebar: React.FC = ({ onItemSelect }) => { const bgClass = 'bg-background'; const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } = - useMcpConfigStore(); + useMcpConfigStore(useShallow((s) => ({ + mcpServers: s.mcpServers, + selectedMcpName: s.selectedMcpName, + setSelectedMcp: s.setSelectedMcp, + setMcpDraft: s.setMcpDraft, + loadMcpConfigs: s.loadMcpConfigs, + deleteMcp: s.deleteMcp, + }))); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null)); diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx index bd3804a6..b52b6703 100644 --- a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react'; import { useUpdateStore } from '@/stores/useUpdateStore'; +import { useShallow } from 'zustand/react/shallow'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo } from '@/lib/device'; import { toast } from '@/components/ui'; @@ -16,7 +17,19 @@ export const AboutSettings: React.FC = () => { const { t } = useI18n(); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); const [showChecking, setShowChecking] = React.useState(false); - const updateStore = useUpdateStore(); + const updateStore = useUpdateStore(useShallow((s) => ({ + info: s.info, + checking: s.checking, + available: s.available, + error: s.error, + downloading: s.downloading, + downloaded: s.downloaded, + progress: s.progress, + runtimeType: s.runtimeType, + checkForUpdates: s.checkForUpdates, + downloadUpdate: s.downloadUpdate, + restartToUpdate: s.restartToUpdate, + }))); const { isMobile } = useDeviceInfo(); const currentVersion = updateStore.info?.currentVersion || 'unknown'; diff --git a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx index 147161d9..433a029d 100644 --- a/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx +++ b/packages/ui/src/components/sections/shared/SettingsSidebarItem.tsx @@ -107,11 +107,11 @@ export const SettingsSidebarItem: React.FC = ({ - {actions.map((action, index) => { + {actions.map((action) => { const Icon = action.icon; return ( { e.stopPropagation(); action.onClick(); diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index a096ae74..f20c98e9 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore'; +import { useShallow } from 'zustand/react/shallow'; import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { @@ -39,17 +40,27 @@ const SkillsCatalogStandalone: React.FC = () => ( const SkillsInstalledPage: React.FC = () => { const { t } = useI18n(); - const { - selectedSkillName, - getSkillByName, + const { + selectedSkillName, + getSkillByName, getSkillDetail, - createSkill, - updateSkill, - skills, - skillDraft, + createSkill, + updateSkill, + skills, + skillDraft, setSkillDraft, setSelectedSkill, - } = useSkillsStore(); + } = useSkillsStore(useShallow((s) => ({ + selectedSkillName: s.selectedSkillName, + getSkillByName: s.getSkillByName, + getSkillDetail: s.getSkillDetail, + createSkill: s.createSkill, + updateSkill: s.updateSkill, + skills: s.skills, + skillDraft: s.skillDraft, + setSkillDraft: s.setSkillDraft, + setSelectedSkill: s.setSelectedSkill, + }))); const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null; const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill); diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 8928be66..b2e334e0 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/dropdown-menu'; import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react'; import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore'; +import { useShallow } from 'zustand/react/shallow'; import { cn } from '@/lib/utils'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector'; @@ -45,7 +46,15 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) => createSkill, deleteSkill, getSkillDetail, - } = useSkillsStore(); + } = useSkillsStore(useShallow((s) => ({ + selectedSkillName: s.selectedSkillName, + skills: s.skills, + setSelectedSkill: s.setSelectedSkill, + setSkillDraft: s.setSkillDraft, + createSkill: s.createSkill, + deleteSkill: s.deleteSkill, + getSkillDetail: s.getSkillDetail, + }))); // Skills are loaded by the Settings shell when this page is active. diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index d481fd92..b85241f3 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -79,7 +79,9 @@ interface AddCatalogDialogProps { export const AddCatalogDialog: React.FC = ({ open, onOpenChange }) => { const { t } = useI18n(); - const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore(); + const scanRepo = useSkillsCatalogStore((s) => s.scanRepo); + const loadCatalog = useSkillsCatalogStore((s) => s.loadCatalog); + const isScanning = useSkillsCatalogStore((s) => s.isScanning); const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); diff --git a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx index ba9eb342..728dbb87 100644 --- a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx @@ -46,7 +46,10 @@ type IdentityOption = { id: string; name: string }; export const InstallFromRepoDialog: React.FC = ({ open, onOpenChange }) => { const { t } = useI18n(); - const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore(); + 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); diff --git a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx index 09b9f7f5..fbfca117 100644 --- a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx @@ -39,7 +39,8 @@ interface InstallSkillDialogProps { export const InstallSkillDialog: React.FC = ({ open, onOpenChange, item }) => { const { t } = useI18n(); - const { installSkills, isInstalling } = useSkillsCatalogStore(); + const installSkills = useSkillsCatalogStore((s) => s.installSkills); + const isInstalling = useSkillsCatalogStore((s) => s.isInstalling); const [scope, setScope] = React.useState<'user' | 'project'>('user'); const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode'); const projects = useProjectsStore((s) => s.projects); diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index d622a383..80dbc207 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -23,6 +23,7 @@ import { import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; +import { useShallow } from 'zustand/react/shallow'; import { cn } from '@/lib/utils'; import type { SkillsCatalogItem } from '@/lib/api/types'; @@ -81,7 +82,21 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo loadedSourceIds, clawdhubHasMoreBySource, lastCatalogError, - } = useSkillsCatalogStore(); + } = useSkillsCatalogStore(useShallow((s) => ({ + sources: s.sources, + itemsBySource: s.itemsBySource, + selectedSourceId: s.selectedSourceId, + setSelectedSource: s.setSelectedSource, + loadCatalog: s.loadCatalog, + loadSource: s.loadSource, + loadMoreClawdHub: s.loadMoreClawdHub, + isLoadingCatalog: s.isLoadingCatalog, + isLoadingSource: s.isLoadingSource, + isLoadingMore: s.isLoadingMore, + loadedSourceIds: s.loadedSourceIds, + clawdhubHasMoreBySource: s.clawdhubHasMoreBySource, + lastCatalogError: s.lastCatalogError, + }))); const [search, setSearch] = React.useState(''); const [addCatalogOpen, setAddCatalogOpen] = React.useState(false); diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 1d5477f2..dd8b4360 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -38,8 +38,11 @@ export const DirectoryExplorerDialog: React.FC = ( onOpenChange, }) => { const { t } = useI18n(); - const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); - const { addProject, getActiveProject } = useProjectsStore(); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const homeDirectory = useDirectoryStore((s) => s.homeDirectory); + const isHomeReady = useDirectoryStore((s) => s.isHomeReady); + const addProject = useProjectsStore((s) => s.addProject); + const getActiveProject = useProjectsStore((s) => s.getActiveProject); const [pendingPath, setPendingPath] = React.useState(null); const [pathInputValue, setPathInputValue] = React.useState(''); const [hasUserSelection, setHasUserSelection] = React.useState(false); diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 299fedb4..a0ae1f48 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -72,8 +72,12 @@ export const SessionDialogs: React.FC = () => { const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); - const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); - const { projects, addProject, activeProjectId } = useProjectsStore(); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const homeDirectory = useDirectoryStore((s) => s.homeDirectory); + const isHomeReady = useDirectoryStore((s) => s.isHomeReady); + const projects = useProjectsStore((s) => s.projects); + const addProject = useProjectsStore((s) => s.addProject); + const activeProjectId = useProjectsStore((s) => s.activeProjectId); const { requestAccess, startAccessing } = useFileSystemAccess(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const useMobileOverlay = isMobile || isTablet || hasTouchInput; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 094ddd37..d49865df 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -45,6 +45,7 @@ import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { useUpdateStore } from '@/stores/useUpdateStore'; +import { useShallow } from 'zustand/react/shallow'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; @@ -290,7 +291,18 @@ export const SessionSidebar: React.FC = ({ const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const updateStore = useUpdateStore(); + const updateStore = useUpdateStore(useShallow((s) => ({ + checkForUpdates: s.checkForUpdates, + available: s.available, + runtimeType: s.runtimeType, + info: s.info, + downloading: s.downloading, + downloaded: s.downloaded, + progress: s.progress, + error: s.error, + downloadUpdate: s.downloadUpdate, + restartToUpdate: s.restartToUpdate, + }))); const sessions = React.useMemo(() => { const liveById = new Map(liveSessions.map((session) => [session.id, session])); diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 9bfc6ac5..3c2acc47 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -165,26 +165,32 @@ export function SessionGroupSection(props: Props): React.ReactNode { [compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents], ); const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); - const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; + const scopeFolders = React.useMemo( + () => folderScopeKey ? getFoldersForScope(folderScopeKey) : [], + [folderScopeKey, getFoldersForScope] + ); - const nodeBySessionId = new Map(); - const collectNodeLookup = (nodes: SessionNode[]) => { - nodes.forEach((node) => { - nodeBySessionId.set(node.session.id, node); - if (node.children.length > 0) { - collectNodeLookup(node.children); - } - }); - }; - collectNodeLookup(sourceGroupNodes); + const nodeBySessionId = React.useMemo(() => { + const map = new Map(); + const collectNodeLookup = (nodes: SessionNode[]) => { + nodes.forEach((node) => { + map.set(node.session.id, node); + if (node.children.length > 0) { + collectNodeLookup(node.children); + } + }); + }; + collectNodeLookup(sourceGroupNodes); + return map; + }, [sourceGroupNodes]); - const allFoldersForGroupBase = scopeFolders.map((folder) => { + const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => { const nodes = folder.sessionIds .map((sid) => nodeBySessionId.get(sid)) .filter((n): n is SessionNode => Boolean(n)) .sort(compareSessionNodes); return { folder, nodes }; - }); + }), [scopeFolders, nodeBySessionId, compareSessionNodes]); const allFoldersForGroup = React.useMemo(() => { const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry])); @@ -238,9 +244,9 @@ export function SessionGroupSection(props: Props): React.ReactNode { return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id)); }, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]); - const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)); - const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)); - const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); + const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]); + const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]); + const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]); if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { return null; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 71f1f0ce..d0984cdd 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -35,7 +35,10 @@ import { import { cn } from '@/lib/utils'; import { isVSCodeRuntime } from '@/lib/desktop'; import { toast } from '@/components/ui'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; +import type { ChildSessionExport } from '@/lib/exportSession'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { useViewportStore } from '@/sync/viewport-store'; @@ -174,8 +177,20 @@ const areEqual = (prev: Props, next: Props): boolean => { if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false; if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false; if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false; - if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false; - if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false; + if (prev.editingId !== next.editingId) { + const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId); + const nextEditingInTree = treeContainsSessionId(next.node, next.editingId); + if (prevEditingInTree || nextEditingInTree) { + return false; + } + } + if (prev.editTitle !== next.editTitle) { + const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId); + const nextEditingInTree = treeContainsSessionId(next.node, next.editingId); + if (prevEditingInTree || nextEditingInTree) { + return false; + } + } if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false; const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false); @@ -267,6 +282,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const liveSession = useSession(session.id); const resolvedSession = liveSession ?? session; + const sessionDirectory = + normalizePath((session as Session & { directory?: string | null }).directory ?? null) + ?? normalizePath(groupDirectory ?? null); + const directoryStore = useDirectoryStore(sessionDirectory ?? undefined); + const sync = useSync(); + const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); const isRowSelected = useSessionMultiSelectStore( React.useCallback((state) => state.selectedIds.has(session.id), [session.id]), @@ -285,15 +306,14 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { walk(root); return out; }, []); + + const [exportDialogOpen, setExportDialogOpen] = React.useState(false); + const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true); + const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`; - const sessionDirectory = - normalizePath((session as Session & { directory?: string | null }).directory ?? null) - ?? normalizePath(groupDirectory ?? null); const isZombie = useViewportStore( React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]), ); - const directoryStore = useDirectoryStore(sessionDirectory ?? undefined); - const sync = useSync(); const sessionStatus = useGlobalSessionStatus(session.id); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; @@ -312,7 +332,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; - const handleExportSession = React.useCallback(async () => { + + const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]); + + const collectChildExports = React.useCallback(async (children: SessionNode[]): Promise<{ children: ChildSessionExport[]; skipped: number }> => { + const results: ChildSessionExport[] = []; + let skipped = 0; + for (const child of children) { + try { + await sync.syncSession(child.session.id); + const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list; + const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent'); + const childAgent = (child.session as Session & { agent?: string }).agent; + const grandChildren = await collectChildExports(child.children); + skipped += grandChildren.skipped; + results.push({ + title: childTitle, + agent: childAgent, + records: childRecords, + children: grandChildren.children, + }); + } catch { + skipped += collectNodeDescendantIds(child).length + 1; + } + } + return { children: results, skipped }; + }, [collectNodeDescendantIds, directoryStore, sync, t]); + + const showSkippedSubtasksWarning = React.useCallback((count: number) => { + if (count <= 0) return; + toast.warning(count === 1 + ? t('sessions.sidebar.session.export.skippedSubtaskSingle', { count }) + : t('sessions.sidebar.session.export.skippedSubtaskMany', { count })); + }, [t]); + + const doExportSession = React.useCallback(async (includeSubtasks: boolean) => { if (!sessionDirectory) { toast.error(t('sessions.sidebar.session.export.nothingToExport')); return; @@ -326,7 +380,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { return; } - const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null); + let childExports: ChildSessionExport[] | undefined; + let skippedSubtaskCount = 0; + if (includeSubtasks && node.children.length > 0) { + const collected = await collectChildExports(node.children); + childExports = collected.children; + skippedSubtaskCount = collected.skipped; + } + + const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null, childExports); const filename = buildExportFilename(resolvedSession.title ?? null); const savedPath = await saveAsMarkdownDesktop(markdown, filename); @@ -343,12 +405,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { }, }, }); + showSkippedSubtasksWarning(skippedSubtaskCount); return; } downloadAsMarkdown(markdown, filename); toast.success(t('sessions.sidebar.session.export.success')); - }, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync, t]); + showSkippedSubtasksWarning(skippedSubtaskCount); + }, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]); + const handleExportSession = React.useCallback(async () => { + if (node.children.length > 0) { + setExportIncludeSubtasks(true); + setExportDialogOpen(true); + return; + } + await doExportSession(false); + }, [doExportSession, node.children.length]); if (editingId === session.id) { return ( @@ -808,6 +880,47 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { {hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext)) : null} + + + + {t('sessions.sidebar.session.export.dialog.title')} + + {descendantCount === 1 + ? t('sessions.sidebar.session.export.dialog.descriptionSingle', { count: descendantCount }) + : t('sessions.sidebar.session.export.dialog.descriptionMany', { count: descendantCount })} + + + + + + + + + ); } diff --git a/packages/ui/src/components/ui/ErrorBoundary.tsx b/packages/ui/src/components/ui/ErrorBoundary.tsx index 93c13329..88f5fd0d 100644 --- a/packages/ui/src/components/ui/ErrorBoundary.tsx +++ b/packages/ui/src/components/ui/ErrorBoundary.tsx @@ -93,7 +93,7 @@ class InnerErrorBoundary extends React.Component {strings.detailsSummary} -
+                  
                     {this.state.error.toString()}
                     {this.state.errorInfo?.componentStack ? `\n\n${strings.componentStackLabel}${this.state.errorInfo.componentStack}` : ''}
                   
diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 405228b8..2ceba4d0 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -250,14 +250,14 @@ export const HelpDialog: React.FC = () => { {t(section.categoryKey)}
- {section.items.map((shortcut, index) => { + {section.items.map((shortcut) => { const displayKeys = shortcut.id ? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides) : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); return (
diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index 43a3ba46..ae57162b 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -42,6 +42,7 @@ const OverlayScrollbarComponent: React.FC = ({ const frameRef = React.useRef(null); const metricsFrameRef = React.useRef(null); const isDraggingRef = React.useRef(false); + const isHoveringRef = React.useRef(false); const lastUserIntentAtRef = React.useRef(0); const dragStartRef = React.useRef<{ pointerX: number; @@ -123,6 +124,10 @@ const OverlayScrollbarComponent: React.FC = ({ if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); } + // Don't schedule hide if hovering over the thumb + if (isHoveringRef.current) { + return; + } hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs); }, [hideDelayMs]); @@ -287,6 +292,21 @@ const OverlayScrollbarComponent: React.FC = ({ scheduleHide(); }; + const handleThumbMouseEnter = React.useCallback(() => { + isHoveringRef.current = true; + // Cancel any pending hide when hovering + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }, []); + + const handleThumbMouseLeave = React.useCallback(() => { + isHoveringRef.current = false; + // Schedule hide when leaving the thumb + scheduleHide(); + }, [scheduleHide]); + const showVertical = vertical.length > 0; const showHorizontal = horizontal.length > 0; if (!showVertical && !showHorizontal) return null; @@ -311,6 +331,8 @@ const OverlayScrollbarComponent: React.FC = ({ onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} + onMouseEnter={handleThumbMouseEnter} + onMouseLeave={handleThumbMouseLeave} /> )} {showHorizontal && ( @@ -325,6 +347,8 @@ const OverlayScrollbarComponent: React.FC = ({ onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} + onMouseEnter={handleThumbMouseEnter} + onMouseLeave={handleThumbMouseLeave} /> )}
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index a057d282..137320be 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -105,7 +105,7 @@ function DialogContent({ data-slot="dialog-content" data-state-slot="dialog" className={cn( - "bg-background text-foreground fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-hidden pwa-dialog-content", + "bg-background text-foreground fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-lg max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-y-auto pwa-dialog-content", className )} {...props} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index eb14584d..8fab38ad 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -1761,6 +1761,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, [loadDirectory, root, toggleExpandedPath]); + const fileRowPermissions = React.useMemo( + () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), + [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] + ); + function renderTree(dirPath: string, depth: number): React.ReactNode { const nodes = childrenByDir[dirPath] ?? []; @@ -1788,7 +1793,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { isMobile={isMobile} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} - permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }} + permissions={fileRowPermissions} downloadFile={files.downloadFile} contextMenuPath={contextMenuPath} setContextMenuPath={setContextMenuPath} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 9763fcb9..db371e77 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -4,6 +4,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; +import { useShallow } from 'zustand/react/shallow'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { copyTextToClipboard } from '@/lib/clipboard'; import { @@ -272,7 +273,14 @@ export const GitView: React.FC = () => { }, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]); const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } = - useGitIdentitiesStore(); + useGitIdentitiesStore(useShallow((s) => ({ + profiles: s.profiles, + globalIdentity: s.globalIdentity, + defaultGitIdentityId: s.defaultGitIdentityId, + loadProfiles: s.loadProfiles, + loadGlobalIdentity: s.loadGlobalIdentity, + loadDefaultGitIdentityId: s.loadDefaultGitIdentityId, + }))); const isGitRepo = useIsGitRepo(currentDirectory ?? null); const status = useGitStatus(currentDirectory ?? null); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 16562326..f59f6e02 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -100,17 +100,16 @@ export const TerminalView: React.FC = () => { const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; const effectiveDirectory = useEffectiveDirectory() ?? null; - const terminalStore = useTerminalStore(); - const terminalSessions = terminalStore.sessions; - const terminalHydrated = terminalStore.hasHydrated; - const ensureDirectory = terminalStore.ensureDirectory; - const createTab = terminalStore.createTab; - const setActiveTab = terminalStore.setActiveTab; - const closeTab = terminalStore.closeTab; - const setTabSessionId = terminalStore.setTabSessionId; - const setTabLifecycle = terminalStore.setTabLifecycle; - const setConnecting = terminalStore.setConnecting; - const appendToBuffer = terminalStore.appendToBuffer; + const terminalSessions = useTerminalStore((s) => s.sessions); + const terminalHydrated = useTerminalStore((s) => s.hasHydrated); + const ensureDirectory = useTerminalStore((s) => s.ensureDirectory); + const createTab = useTerminalStore((s) => s.createTab); + const setActiveTab = useTerminalStore((s) => s.setActiveTab); + const closeTab = useTerminalStore((s) => s.closeTab); + const setTabSessionId = useTerminalStore((s) => s.setTabSessionId); + const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle); + const setConnecting = useTerminalStore((s) => s.setConnecting); + const appendToBuffer = useTerminalStore((s) => s.appendToBuffer); const directoryTerminalState = React.useMemo(() => { if (!effectiveDirectory) return undefined; diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx index 511af7c6..e08758ba 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx @@ -456,11 +456,14 @@ export const AgentManagerEmptyState: React.FC = ({

{t('agentManager.empty.setupCommands.label')} - {setupCommands.filter(cmd => cmd.trim()).length > 0 && ( - - {' '}({t('agentManager.empty.setupCommands.configured', { count: setupCommands.filter(cmd => cmd.trim()).length })}) - - )} + {(() => { + const trimmedCommandCount = setupCommands.filter(cmd => cmd.trim()).length; + return trimmedCommandCount > 0 ? ( + + {' '}({t('agentManager.empty.setupCommands.configured', { count: trimmedCommandCount })}) + + ) : null; + })()}

= ({
    {highlights.map((highlight, index) => ( -
  • +
  • {highlight}
  • ))} diff --git a/packages/ui/src/components/views/git/ConflictDialog.tsx b/packages/ui/src/components/views/git/ConflictDialog.tsx index c7905fae..f12f9c62 100644 --- a/packages/ui/src/components/views/git/ConflictDialog.tsx +++ b/packages/ui/src/components/views/git/ConflictDialog.tsx @@ -204,9 +204,9 @@ export const ConflictDialog: React.FC = ({
    - {displayFiles.map((file, index) => ( + {displayFiles.map((file) => (
  • diff --git a/packages/ui/src/components/views/git/HistoryCommitRow.tsx b/packages/ui/src/components/views/git/HistoryCommitRow.tsx index 87b5f017..223b83e0 100644 --- a/packages/ui/src/components/views/git/HistoryCommitRow.tsx +++ b/packages/ui/src/components/views/git/HistoryCommitRow.tsx @@ -46,14 +46,14 @@ function getChangeTypeColor(changeType: string) { } } -export const HistoryCommitRow: React.FC = ({ +export const HistoryCommitRow = React.memo(({ entry, isExpanded, onToggle, files, isLoadingFiles, onCopyHash, -}) => { +}: HistoryCommitRowProps) => { const { t } = useI18n(); return (
  • @@ -159,4 +159,4 @@ export const HistoryCommitRow: React.FC = ({ )}
  • ); -}; +}); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 44930041..6d070539 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -342,8 +342,12 @@ export const useKeyboardShortcuts = () => { const target = e.target as Element | null; const isInsideDialog = Boolean(target?.closest('[role="dialog"]')); const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]')); + const isInsideTerminal = Boolean( + target?.closest('.terminal-viewport-container') || + target?.getAttribute('data-terminal-hidden-input') === 'true' + ); - if (isInsideDialog || isSettingsMounted) { + if (isInsideDialog || isSettingsMounted || isInsideTerminal) { resetAbortPriming(); return; } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 6bc15fec..189f8733 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -100,7 +100,7 @@ export const useMenuActions = ( const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); - const { addProject } = useProjectsStore(); + const addProject = useProjectsStore((s) => s.addProject); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); const { requestAccess, startAccessing } = useFileSystemAccess(); const { setThemeMode } = useThemeSystem(); diff --git a/packages/ui/src/hooks/useMobileKeyboardManager.ts b/packages/ui/src/hooks/useMobileKeyboardManager.ts new file mode 100644 index 00000000..230596f9 --- /dev/null +++ b/packages/ui/src/hooks/useMobileKeyboardManager.ts @@ -0,0 +1,262 @@ +import React from 'react'; +import { useUIStore } from '@/stores/useUIStore'; + +/** + * Detects mobile keyboard open/close via the Visual Viewport API and manages: + * - `--oc-keyboard-inset` CSS variable on :root + * - `--oc-visual-viewport-height` / `--oc-visual-viewport-offset-top` CSS variables + * - `--oc-keyboard-home-indicator` CSS variable (iOS home bar) + * - `--oc-keyboard-avoid-offset` on the active input's keyboard-avoid target + * - `isKeyboardOpen` state in the UI store + * + * Platform-specific handling: + * - iOS: sticky inset with home indicator padding + * - Android: resize-based detection with `maxObservedLayoutHeight` tracking + */ +export function useMobileKeyboardManager(isMobile: boolean): void { + React.useEffect(() => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + + let stickyKeyboardInset = 0; + let ignoreOpenUntilZero = false; + let previousHeight = 0; + let maxObservedLayoutHeight = 0; + let previousOrientation = ''; + let keyboardAvoidTarget: HTMLElement | null = null; + + const setKeyboardOpen = useUIStore.getState().setKeyboardOpen; + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent; + const isAndroid = /Android/i.test(userAgent); + const isIOS = /iPad|iPhone|iPod/.test(userAgent); + + const clearKeyboardAvoidTarget = () => { + if (!keyboardAvoidTarget) { + return; + } + keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px'); + keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active'); + keyboardAvoidTarget = null; + }; + + const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => { + if (!active) { + return null; + } + const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id'); + if (explicitTargetId) { + const explicitTarget = document.getElementById(explicitTargetId); + if (explicitTarget instanceof HTMLElement) { + return explicitTarget; + } + } + const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null; + if (markedTarget) { + if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') { + return null; + } + return markedTarget; + } + if (active.classList.contains('overlay-scrollbar-container')) { + const parent = active.parentElement; + if (parent instanceof HTMLElement) { + return parent; + } + } + return active; + }; + + const forceKeyboardClosed = () => { + stickyKeyboardInset = 0; + ignoreOpenUntilZero = true; + root.style.setProperty('--oc-keyboard-inset', '0px'); + setKeyboardOpen(false); + }; + + const isTextInputTarget = (element: HTMLElement | null) => { + if (!element) { + return false; + } + const tagName = element.tagName; + const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; + return isInput || element.isContentEditable; + }; + + let rafId = 0; + + const updateVisualViewport = () => { + const viewport = window.visualViewport; + + const height = viewport ? Math.round(viewport.height) : window.innerHeight; + const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0; + const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait'; + + root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`); + root.style.setProperty('--oc-visual-viewport-height', `${height}px`); + + const active = document.activeElement as HTMLElement | null; + const isTextTarget = isTextInputTarget(active); + + const layoutHeight = Math.round(root.clientHeight || window.innerHeight); + if (previousOrientation !== orientation) { + previousOrientation = orientation; + maxObservedLayoutHeight = layoutHeight; + } else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) { + maxObservedLayoutHeight = layoutHeight; + } + const viewportSum = height + offsetTop; + const rawInset = Math.max(0, layoutHeight - viewportSum); + const rawAndroidResizeInset = isAndroid + ? Math.max(0, maxObservedLayoutHeight - layoutHeight) + : 0; + + const openThreshold = isTextTarget ? 120 : 180; + const measuredInset = rawInset >= openThreshold ? rawInset : 0; + const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold + ? rawAndroidResizeInset + : 0; + const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset); + + if (ignoreOpenUntilZero) { + if (effectiveMeasuredInset === 0) { + ignoreOpenUntilZero = false; + } + stickyKeyboardInset = 0; + } else if (stickyKeyboardInset === 0) { + if (effectiveMeasuredInset > 0 && isTextTarget) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } + } else { + const closingByHeight = !isTextTarget && height > previousHeight + 6; + + if (effectiveMeasuredInset === 0) { + stickyKeyboardInset = 0; + setKeyboardOpen(false); + } else if (closingByHeight) { + forceKeyboardClosed(); + } else if (effectiveMeasuredInset > 0 && isTextTarget) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } else if (effectiveMeasuredInset > stickyKeyboardInset) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } + } + + root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`); + previousHeight = height; + + const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0; + root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`); + + const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null; + + if (!isMobile || !avoidTarget || !active) { + clearKeyboardAvoidTarget(); + } else { + if (avoidTarget !== keyboardAvoidTarget) { + clearKeyboardAvoidTarget(); + keyboardAvoidTarget = avoidTarget; + } + const viewportBottom = offsetTop + height; + const rect = active.getBoundingClientRect(); + const overlap = rect.bottom - viewportBottom; + const clearance = 8; + const keyboardInset = Math.max(stickyKeyboardInset, effectiveMeasuredInset); + const avoidOffset = overlap > clearance && keyboardInset > 0 + ? Math.min(overlap, keyboardInset) + : 0; + const target = keyboardAvoidTarget; + if (target) { + target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`); + target.setAttribute('data-keyboard-avoid-active', 'true'); + } + } + + if (isMobile && isTextTarget) { + const scroller = document.scrollingElement; + if (scroller && scroller.scrollTop !== 0) { + scroller.scrollTop = 0; + } + if (window.scrollY !== 0) { + window.scrollTo(0, 0); + } + } + }; + + const scheduleVisualViewportUpdate = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + updateVisualViewport(); + }); + }; + + updateVisualViewport(); + + const viewport = window.visualViewport; + viewport?.addEventListener('resize', scheduleVisualViewportUpdate); + viewport?.addEventListener('scroll', scheduleVisualViewportUpdate); + window.addEventListener('resize', scheduleVisualViewportUpdate); + window.addEventListener('orientationchange', scheduleVisualViewportUpdate); + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (isTextInputTarget(target)) { + ignoreOpenUntilZero = false; + } + scheduleVisualViewportUpdate(); + }; + document.addEventListener('focusin', handleFocusIn, true); + + const handleFocusOut = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (!isTextInputTarget(target)) { + return; + } + + const related = event.relatedTarget as HTMLElement | null; + if (isTextInputTarget(related)) { + return; + } + + window.requestAnimationFrame(() => { + if (isTextInputTarget(document.activeElement as HTMLElement | null)) { + return; + } + + const currentViewport = window.visualViewport; + const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight; + const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0; + const layoutHeight = Math.round(root.clientHeight || window.innerHeight); + const viewportSum = height + offsetTop; + const rawInset = Math.max(0, layoutHeight - viewportSum); + + if (rawInset > 0) { + updateVisualViewport(); + return; + } + + forceKeyboardClosed(); + updateVisualViewport(); + }); + }; + + document.addEventListener('focusout', handleFocusOut, true); + + return () => { + if (rafId) cancelAnimationFrame(rafId); + viewport?.removeEventListener('resize', scheduleVisualViewportUpdate); + viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate); + window.removeEventListener('resize', scheduleVisualViewportUpdate); + window.removeEventListener('orientationchange', scheduleVisualViewportUpdate); + document.removeEventListener('focusin', handleFocusIn, true); + document.removeEventListener('focusout', handleFocusOut, true); + clearKeyboardAvoidTarget(); + }; + }, [isMobile]); +} diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts index 5621da74..febd0b24 100644 --- a/packages/ui/src/lib/desktopBoot.test.ts +++ b/packages/ui/src/lib/desktopBoot.test.ts @@ -69,7 +69,7 @@ describe('resolveDesktopBootView', () => { isDesktopShell: true, bootOutcome: { target: 'local', status: 'unreachable' }, }), - ).toEqual({ screen: 'recovery', variant: 'local-unreachable' }); + ).toEqual({ screen: 'recovery', variant: 'local-unavailable' }); }); test('returns recovery view for remote missing', () => { @@ -257,9 +257,9 @@ describe('getInjectedBootOutcome', () => { test('returns valid outcome for well-formed main-local', () => { const w = mockWindow(); - w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' }; + w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { target: 'local', status: 'ok' }; try { - expect(getInjectedBootOutcome()).toEqual({ kind: 'main-local' }); + expect(getInjectedBootOutcome()).toEqual({ target: 'local', status: 'ok' }); } finally { restoreWindow(); } @@ -339,7 +339,7 @@ describe('getBootInjectionStatus', () => { test('returns "valid" when global is present and well-formed', () => { const w = mockWindow(); - w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' }; + w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { target: 'local', status: 'ok' }; try { expect(getBootInjectionStatus()).toBe('valid'); } finally { diff --git a/packages/ui/src/lib/exportSession.ts b/packages/ui/src/lib/exportSession.ts index 34fc0bf9..566e5584 100644 --- a/packages/ui/src/lib/exportSession.ts +++ b/packages/ui/src/lib/exportSession.ts @@ -5,6 +5,13 @@ import { getRevealLabelKey } from '@/lib/utils'; type SessionMessageRecord = { info: Message; parts: Part[] }; +export type ChildSessionExport = { + title: string; + agent?: string; + records: SessionMessageRecord[]; + children: ChildSessionExport[]; +}; + function formatTimestamp(timestamp: number | undefined): string { if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { return ''; @@ -65,9 +72,29 @@ function formatMessageAsMarkdown(record: SessionMessageRecord): string { return `${role}\n\n${text}`; } +function formatChildSessionAsMarkdown(child: ChildSessionExport, depth: number): string { + const heading = '#'.repeat(Math.min(depth + 1, 6)); + const agentLabel = child.agent ? ` — ${child.agent}` : ''; + const childHeader = `${heading} Sub-agent: ${child.title}${agentLabel}\n\n---\n\n`; + + const childBody = child.records + .map(formatMessageAsMarkdown) + .filter(Boolean) + .join('\n\n---\n\n'); + + const parts = [childHeader + childBody]; + + for (const grandchild of child.children) { + parts.push(formatChildSessionAsMarkdown(grandchild, depth + 1)); + } + + return parts.join('\n\n---\n\n'); +} + export function formatSessionAsMarkdown( messages: SessionMessageRecord[], sessionTitle?: string | null, + childSessions?: ChildSessionExport[], ): string { const title = sessionTitle?.trim() || 'Session'; const date = new Date().toISOString().split('T')[0]; @@ -79,7 +106,16 @@ export function formatSessionAsMarkdown( .filter(Boolean) .join('\n\n---\n\n'); - return header + body; + let result = header + body; + + if (childSessions && childSessions.length > 0) { + const childMarkdown = childSessions + .map((child) => formatChildSessionAsMarkdown(child, 1)) + .join('\n\n---\n\n'); + result += '\n\n---\n\n' + childMarkdown; + } + + return result; } export function downloadAsMarkdown(content: string, filename: string): void { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 69d9bbe9..c7cb2b9b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -236,6 +236,14 @@ export const dict = { 'sessions.sidebar.session.export.nothingToExport': 'Nothing to export', 'sessions.sidebar.session.export.success': 'Session exported', 'sessions.sidebar.session.export.failedRevealPath': 'Failed to reveal path', + 'sessions.sidebar.session.export.untitledSubagent': 'Untitled Sub-agent', + 'sessions.sidebar.session.export.skippedSubtaskSingle': 'Exported session, but skipped {count} sub-agent task that could not be loaded.', + 'sessions.sidebar.session.export.skippedSubtaskMany': 'Exported session, but skipped {count} sub-agent tasks that could not be loaded.', + 'sessions.sidebar.session.export.dialog.title': 'Export Markdown', + 'sessions.sidebar.session.export.dialog.descriptionSingle': 'This session has {count} sub-agent task. Include it in the export?', + 'sessions.sidebar.session.export.dialog.descriptionMany': 'This session has {count} sub-agent tasks. Include them in the export?', + 'sessions.sidebar.session.export.dialog.includeSubtasks': 'Include sub-agent tasks', + 'sessions.sidebar.session.export.dialog.confirm': 'Export', 'sessions.sidebar.session.status.active': 'Session active', 'sessions.sidebar.session.status.unread': 'Unread updates', 'sessions.sidebar.session.status.pinned': 'Pinned session', @@ -1958,6 +1966,10 @@ export const dict = { 'onboarding.desktopRecovery.common.useRemote': 'Use Remote', 'onboarding.desktopRecovery.actions.retrying': 'Retrying…', 'onboarding.desktopRecovery.actions.retryConnection': 'Retry Connection', + 'startup.initRecovery.title': 'Startup failed', + 'startup.initRecovery.description': 'OpenChamber could not finish initialization. Check that the server is running, then retry.', + 'startup.initRecovery.retry': 'Retry', + 'startup.initRecovery.retrying': 'Retrying…', 'onboarding.desktopRecovery.placeholders.remoteServer': 'the remote server', 'onboarding.desktopRecovery.placeholders.unknownServer': 'unknown', 'vscodeLayout.title.chat': 'Chat', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 80277450..97a5be59 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -237,6 +237,14 @@ export const dict: Record = { "sessions.sidebar.session.export.nothingToExport": "No hay nada para exportar", "sessions.sidebar.session.export.success": "Sesión exportada", "sessions.sidebar.session.export.failedRevealPath": "No se pudo mostrar la ruta", + "sessions.sidebar.session.export.untitledSubagent": "Subagente sin título", + "sessions.sidebar.session.export.skippedSubtaskSingle": "La sesión se exportó, pero se omitió {count} tarea de subagente que no se pudo cargar.", + "sessions.sidebar.session.export.skippedSubtaskMany": "La sesión se exportó, pero se omitieron {count} tareas de subagente que no se pudieron cargar.", + "sessions.sidebar.session.export.dialog.title": "Exportar Markdown", + "sessions.sidebar.session.export.dialog.descriptionSingle": "Esta sesión tiene {count} tarea de subagente. ¿Incluirla en la exportación?", + "sessions.sidebar.session.export.dialog.descriptionMany": "Esta sesión tiene {count} tareas de subagente. ¿Incluirlas en la exportación?", + "sessions.sidebar.session.export.dialog.includeSubtasks": "Incluir tareas de subagente", + "sessions.sidebar.session.export.dialog.confirm": "Exportar", "sessions.sidebar.session.status.active": "Sesión activa", "sessions.sidebar.session.status.unread": "Actualizaciones no leídas", "sessions.sidebar.session.status.pinned": "Sesión anclada", @@ -1959,6 +1967,10 @@ export const dict: Record = { "onboarding.desktopRecovery.common.useRemote": "Usar remoto", "onboarding.desktopRecovery.actions.retrying": "Reintentando…", "onboarding.desktopRecovery.actions.retryConnection": "Reintentar conexión", + "startup.initRecovery.title": "Error al iniciar", + "startup.initRecovery.description": "OpenChamber no pudo completar la inicialización. Comprueba que el servidor esté en ejecución y vuelve a intentarlo.", + "startup.initRecovery.retry": "Reintentar", + "startup.initRecovery.retrying": "Reintentando…", "onboarding.desktopRecovery.placeholders.remoteServer": "el servidor remoto", "onboarding.desktopRecovery.placeholders.unknownServer": "desconocido", "vscodeLayout.title.chat": "Chat", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 056dd78a..be5967f1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -237,6 +237,14 @@ export const dict: Record = { "sessions.sidebar.session.export.nothingToExport": "Não há nada para exportar", "sessions.sidebar.session.export.success": "Sessão exportada", "sessions.sidebar.session.export.failedRevealPath": "Não foi possível mostrar o caminho", + "sessions.sidebar.session.export.untitledSubagent": "Subagente sem título", + "sessions.sidebar.session.export.skippedSubtaskSingle": "A sessão foi exportada, mas {count} tarefa de subagente não pôde ser carregada e foi ignorada.", + "sessions.sidebar.session.export.skippedSubtaskMany": "A sessão foi exportada, mas {count} tarefas de subagente não puderam ser carregadas e foram ignoradas.", + "sessions.sidebar.session.export.dialog.title": "Exportar Markdown", + "sessions.sidebar.session.export.dialog.descriptionSingle": "Esta sessão tem {count} tarefa de subagente. Incluí-la na exportação?", + "sessions.sidebar.session.export.dialog.descriptionMany": "Esta sessão tem {count} tarefas de subagente. Incluí-las na exportação?", + "sessions.sidebar.session.export.dialog.includeSubtasks": "Incluir tarefas de subagente", + "sessions.sidebar.session.export.dialog.confirm": "Exportar", "sessions.sidebar.session.status.active": "Sessão ativa", "sessions.sidebar.session.status.unread": "Atualizações não lidas", "sessions.sidebar.session.status.pinned": "Sessão fixada", @@ -1959,6 +1967,10 @@ export const dict: Record = { "onboarding.desktopRecovery.common.useRemote": "Usar remoto", "onboarding.desktopRecovery.actions.retrying": "Retentendo…", "onboarding.desktopRecovery.actions.retryConnection": "Tentar novamente conexão", + "startup.initRecovery.title": "Falha ao iniciar", + "startup.initRecovery.description": "O OpenChamber não conseguiu concluir a inicialização. Verifique se o servidor está em execução e tente novamente.", + "startup.initRecovery.retry": "Tentar novamente", + "startup.initRecovery.retrying": "Tentando novamente…", "onboarding.desktopRecovery.placeholders.remoteServer": "o servidor remoto", "onboarding.desktopRecovery.placeholders.unknownServer": "desconhecido", "vscodeLayout.title.chat": "Chat", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index adc817e9..354ccd6a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -237,6 +237,14 @@ export const dict: Record = { "sessions.sidebar.session.export.nothingToExport": "Нічого для експорту", "sessions.sidebar.session.export.success": "Сесія експортовано", "sessions.sidebar.session.export.failedRevealPath": "Не вдалося відкрити шлях", + "sessions.sidebar.session.export.untitledSubagent": "Під-агент без назви", + "sessions.sidebar.session.export.skippedSubtaskSingle": "Сесію експортовано, але пропущено {count} завдання під-агента, яке не вдалося завантажити.", + "sessions.sidebar.session.export.skippedSubtaskMany": "Сесію експортовано, але пропущено {count} завдань під-агента, які не вдалося завантажити.", + "sessions.sidebar.session.export.dialog.title": "Експорт Markdown", + "sessions.sidebar.session.export.dialog.descriptionSingle": "Ця сесія має {count} завдання під-агента. Додати його до експорту?", + "sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?", + "sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів", + "sessions.sidebar.session.export.dialog.confirm": "Експортувати", "sessions.sidebar.session.status.active": "Сесія активний", "sessions.sidebar.session.status.unread": "Непрочитані оновлення", "sessions.sidebar.session.status.pinned": "Закріплений сесія", @@ -1959,6 +1967,10 @@ export const dict: Record = { "onboarding.desktopRecovery.common.useRemote": "Використовувати Remote", "onboarding.desktopRecovery.actions.retrying": "Повторна спроба…", "onboarding.desktopRecovery.actions.retryConnection": "Повторити підключення", + "startup.initRecovery.title": "Не вдалося запустити", + "startup.initRecovery.description": "OpenChamber не зміг завершити ініціалізацію. Перевірте, що сервер запущений, і повторіть спробу.", + "startup.initRecovery.retry": "Повторити спробу", + "startup.initRecovery.retrying": "Повторна спроба…", "onboarding.desktopRecovery.placeholders.remoteServer": "віддалений сервер", "onboarding.desktopRecovery.placeholders.unknownServer": "невідомий", "vscodeLayout.title.chat": "Чат", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index b039a86a..e5093503 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -237,6 +237,14 @@ export const dict: Record = { 'sessions.sidebar.session.export.nothingToExport': '没有可导出的内容', 'sessions.sidebar.session.export.success': '会话已导出', 'sessions.sidebar.session.export.failedRevealPath': '显示路径失败', + 'sessions.sidebar.session.export.untitledSubagent': '未命名子代理', + 'sessions.sidebar.session.export.skippedSubtaskSingle': '会话已导出,但跳过了 {count} 个无法加载的子代理任务。', + 'sessions.sidebar.session.export.skippedSubtaskMany': '会话已导出,但跳过了 {count} 个无法加载的子代理任务。', + 'sessions.sidebar.session.export.dialog.title': '导出 Markdown', + 'sessions.sidebar.session.export.dialog.descriptionSingle': '此会话有 {count} 个子代理任务。是否包含在导出中?', + 'sessions.sidebar.session.export.dialog.descriptionMany': '此会话有 {count} 个子代理任务。是否包含在导出中?', + 'sessions.sidebar.session.export.dialog.includeSubtasks': '包含子代理任务', + 'sessions.sidebar.session.export.dialog.confirm': '导出', 'sessions.sidebar.session.status.active': '会话活跃中', 'sessions.sidebar.session.status.unread': '有未读更新', 'sessions.sidebar.session.status.pinned': '已置顶会话', @@ -1959,6 +1967,10 @@ export const dict: Record = { 'onboarding.desktopRecovery.common.useRemote': '使用远程', 'onboarding.desktopRecovery.actions.retrying': '重试中…', 'onboarding.desktopRecovery.actions.retryConnection': '重试连接', + 'startup.initRecovery.title': '启动失败', + 'startup.initRecovery.description': 'OpenChamber 未能完成初始化。请检查服务器是否正在运行,然后重试。', + 'startup.initRecovery.retry': '重试', + 'startup.initRecovery.retrying': '重试中…', 'onboarding.desktopRecovery.placeholders.remoteServer': '远程服务器', 'onboarding.desktopRecovery.placeholders.unknownServer': '未知服务器', 'vscodeLayout.title.chat': '聊天', diff --git a/packages/ui/src/lib/messages/synthetic.test.ts b/packages/ui/src/lib/messages/synthetic.test.ts new file mode 100644 index 00000000..8f1aa807 --- /dev/null +++ b/packages/ui/src/lib/messages/synthetic.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import type { Part } from "@opencode-ai/sdk/v2" +import { isSyntheticPart, isFullySyntheticMessage, filterSyntheticParts } from "./synthetic" + +function createTextPart(id: string, text: string, synthetic?: boolean): Part { + return { + id, + sessionID: "session-1", + messageID: "message-1", + type: "text", + text, + ...(synthetic !== undefined ? { synthetic } : {}), + } as Part +} + +function createFilePart(id: string, url: string): Part { + return { + id, + sessionID: "session-1", + messageID: "message-1", + type: "file", + mime: "text/plain", + url, + } as Part +} + +describe("isSyntheticPart", () => { + test("returns false for undefined", () => { + expect(isSyntheticPart(undefined)).toBe(false) + }) + + test("returns false for non-object", () => { + expect(isSyntheticPart(null as unknown as Part)).toBe(false) + expect(isSyntheticPart("string" as unknown as Part)).toBe(false) + }) + + test("returns false for parts without synthetic property", () => { + const part = createTextPart("1", "hello") + expect(isSyntheticPart(part)).toBe(false) + }) + + test("returns false for parts with synthetic: false", () => { + const part = createTextPart("1", "hello", false) + expect(isSyntheticPart(part)).toBe(false) + }) + + test("returns true for parts with synthetic: true", () => { + const part = createTextPart("1", "file content here", true) + expect(isSyntheticPart(part)).toBe(true) + }) + + test("returns false for file parts", () => { + const part = createFilePart("1", "file:///path/to/file") + expect(isSyntheticPart(part)).toBe(false) + }) +}) + +describe("isFullySyntheticMessage", () => { + test("returns false for undefined", () => { + expect(isFullySyntheticMessage(undefined)).toBe(false) + }) + + test("returns false for empty array", () => { + expect(isFullySyntheticMessage([])).toBe(false) + }) + + test("returns false when all parts are non-synthetic", () => { + const parts = [ + createTextPart("1", "hello"), + createFilePart("2", "file:///path"), + ] + expect(isFullySyntheticMessage(parts)).toBe(false) + }) + + test("returns false when some parts are synthetic", () => { + const parts = [ + createTextPart("1", "user prompt"), + createTextPart("2", "file content", true), + ] + expect(isFullySyntheticMessage(parts)).toBe(false) + }) + + test("returns true when all parts are synthetic", () => { + const parts = [ + createTextPart("1", "file content 1", true), + createTextPart("2", "file content 2", true), + ] + expect(isFullySyntheticMessage(parts)).toBe(true) + }) +}) + +describe("filterSyntheticParts", () => { + test("returns empty array for undefined", () => { + expect(filterSyntheticParts(undefined)).toEqual([]) + }) + + test("returns empty array for empty array", () => { + expect(filterSyntheticParts([])).toEqual([]) + }) + + test("returns all parts when no synthetic parts exist", () => { + const parts = [ + createTextPart("1", "hello"), + createFilePart("2", "file:///path"), + ] + expect(filterSyntheticParts(parts)).toEqual(parts) + }) + + test("filters out synthetic parts when non-synthetic parts exist", () => { + const userPart = createTextPart("1", "user prompt") + const syntheticPart = createTextPart("2", "file content", true) + const parts = [userPart, syntheticPart] + expect(filterSyntheticParts(parts)).toEqual([userPart]) + }) + + test("keeps synthetic parts when all parts are synthetic", () => { + const parts = [ + createTextPart("1", "file content 1", true), + createTextPart("2", "file content 2", true), + ] + expect(filterSyntheticParts(parts)).toEqual(parts) + }) +}) diff --git a/packages/ui/src/stores/utils/permissionAutoAccept.test.ts b/packages/ui/src/stores/utils/permissionAutoAccept.test.ts new file mode 100644 index 00000000..3ddb61a7 --- /dev/null +++ b/packages/ui/src/stores/utils/permissionAutoAccept.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { autoRespondsPermission, type PermissionAutoAcceptMap } from "./permissionAutoAccept" + +function makeSession(id: string, parentID?: string): Session { + return { id, parentID } as Session +} + +describe("autoRespondsPermission", () => { + test("returns false when autoAccept is empty", () => { + expect(autoRespondsPermission({ + autoAccept: {}, + sessions: [makeSession("s1")], + sessionID: "s1", + })).toBe(false) + }) + + test("returns true when session has autoAccept enabled", () => { + const autoAccept: PermissionAutoAcceptMap = { s1: true } + expect(autoRespondsPermission({ + autoAccept, + sessions: [makeSession("s1")], + sessionID: "s1", + })).toBe(true) + }) + + test("returns false when session has autoAccept disabled", () => { + const autoAccept: PermissionAutoAcceptMap = { s1: false } + expect(autoRespondsPermission({ + autoAccept, + sessions: [makeSession("s1")], + sessionID: "s1", + })).toBe(false) + }) + + test("returns true when parent has autoAccept enabled", () => { + const autoAccept: PermissionAutoAcceptMap = { parent: true } + const sessions = [ + makeSession("parent"), + makeSession("child", "parent"), + ] + expect(autoRespondsPermission({ + autoAccept, + sessions, + sessionID: "child", + })).toBe(true) + }) + + test("returns true when grandparent has autoAccept enabled", () => { + const autoAccept: PermissionAutoAcceptMap = { grandparent: true } + const sessions = [ + makeSession("grandparent"), + makeSession("parent", "grandparent"), + makeSession("child", "parent"), + ] + expect(autoRespondsPermission({ + autoAccept, + sessions, + sessionID: "child", + })).toBe(true) + }) + + test("returns false when only sibling has autoAccept enabled", () => { + const autoAccept: PermissionAutoAcceptMap = { sibling: true } + const sessions = [ + makeSession("parent"), + makeSession("sibling", "parent"), + makeSession("child", "parent"), + ] + expect(autoRespondsPermission({ + autoAccept, + sessions, + sessionID: "child", + })).toBe(false) + }) + + test("child autoAccept overrides parent", () => { + const autoAccept: PermissionAutoAcceptMap = { parent: true, child: false } + const sessions = [ + makeSession("parent"), + makeSession("child", "parent"), + ] + expect(autoRespondsPermission({ + autoAccept, + sessions, + sessionID: "child", + })).toBe(false) + }) + + test("returns false for unknown session", () => { + const autoAccept: PermissionAutoAcceptMap = { s1: true } + expect(autoRespondsPermission({ + autoAccept, + sessions: [], + sessionID: "unknown", + })).toBe(false) + }) +}) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts new file mode 100644 index 00000000..2c66ac60 --- /dev/null +++ b/packages/ui/src/sync/session-actions.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test, beforeEach, mock } from "bun:test" +import type { PermissionRequest } from "@/types/permission" + +// Mock SDK client that records permission.reply / question.reply calls +const replyCalls: Array<{ method: string; params: Record }> = [] + +const mockScopedClient = { + permission: { + reply: mock((params: Record) => { + replyCalls.push({ method: "permission.reply", params }) + return Promise.resolve({ data: true }) + }), + }, + question: { + reply: mock((params: Record) => { + replyCalls.push({ method: "question.reply", params }) + return Promise.resolve({ data: true }) + }), + reject: mock((params: Record) => { + replyCalls.push({ method: "question.reject", params }) + return Promise.resolve({ data: true }) + }), + }, +} + +const mockSdk = { + permission: { + reply: mock((params: Record) => { + replyCalls.push({ method: "permission.reply", params }) + return Promise.resolve({ data: true }) + }), + }, + question: { + reply: mock((params: Record) => { + replyCalls.push({ method: "question.reply", params }) + return Promise.resolve({ data: true }) + }), + reject: mock((params: Record) => { + replyCalls.push({ method: "question.reject", params }) + return Promise.resolve({ data: true }) + }), + }, +} + +// Mock opencodeClient singleton +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getScopedSdkClient: (_: string) => mockScopedClient, + getDirectory: () => "/test/project", + }, +})) + +// Mock useConfigStore +mock.module("@/stores/useConfigStore", () => ({ + useConfigStore: { + getState: () => ({ + isConnected: true, + hasEverConnected: true, + }), + }, +})) + +// Mock useSessionUIStore +mock.module("./session-ui-store", () => ({ + useSessionUIStore: { + getState: () => ({ + getDirectoryForSession: (sessionId: string) => { + if (sessionId === "session-a") return "/test/project" + if (sessionId === "session-b") return "/other/project" + return null + }, + }), + }, +})) + +// Mock useInputStore (imported but not used in permission functions) +mock.module("./input-store", () => ({ + useInputStore: {}, +})) + +// Mock useGlobalSessionsStore (imported but not used in permission functions) +mock.module("@/stores/useGlobalSessionsStore", () => ({ + useGlobalSessionsStore: {}, +})) + +// Mock sync-refs (imported but not used in permission functions) +mock.module("./sync-refs", () => ({ + registerSessionDirectory: () => {}, +})) + +import { create, type StoreApi } from "zustand" +import { INITIAL_STATE } from "./types" +import type { DirectoryStore } from "./child-store" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +function createStore(permissions: Record): StoreApi { + return create()((set) => ({ + ...INITIAL_STATE, + permission: permissions, + patch: (partial) => set(partial), + replace: (next) => set(next), + })) +} + +function createChildStores(entries: Array<[string, StoreApi]>) { + return { + children: new Map(entries), + ensureChild: (dir: string) => { + const store = new Map(entries).get(dir) + if (!store) throw new Error(`No store for ${dir}`) + return store + }, + } as unknown as import("./child-store").ChildStoreManager +} + +describe("respondToPermission passes directory", () => { + beforeEach(() => { + replyCalls.length = 0 + }) + + test("passes directory from child store when permission is found", async () => { + const permission: PermissionRequest = { + id: "perm-1", + sessionID: "session-a", + permission: "bash", + patterns: [], + metadata: {}, + always: [], + } + + const store = createStore({ "session-a": [permission] }) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, respondToPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + await respondToPermission("session-a", "perm-1", "once") + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("perm-1") + expect(replyCalls[0].params.reply).toBe("once") + expect(replyCalls[0].params.directory).toBe("/test/project") + }) + + test("passes directory from session mapping when permission not in store", async () => { + const childStores = createChildStores([]) + + const { setActionRefs, respondToPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + await respondToPermission("session-b", "perm-2", "always") + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("perm-2") + expect(replyCalls[0].params.reply).toBe("always") + expect(replyCalls[0].params.directory).toBe("/other/project") + }) + + test("passes directory from current directory as last resort", async () => { + const childStores = createChildStores([]) + + const { setActionRefs, respondToPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/fallback/dir") + + await respondToPermission("unknown-session", "perm-3", "reject") + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("perm-3") + expect(replyCalls[0].params.reply).toBe("reject") + expect(replyCalls[0].params.directory).toBe("/fallback/dir") + }) +}) + +describe("dismissPermission passes directory", () => { + beforeEach(() => { + replyCalls.length = 0 + }) + + test("passes directory and reply=reject", async () => { + const permission: PermissionRequest = { + id: "perm-10", + sessionID: "session-a", + permission: "edit", + patterns: [], + metadata: {}, + always: [], + } + + const store = createStore({ "session-a": [permission] }) + const childStores = createChildStores([["/test/project", store]]) + + const { setActionRefs, dismissPermission } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + await dismissPermission("session-a", "perm-10") + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("perm-10") + expect(replyCalls[0].params.reply).toBe("reject") + expect(replyCalls[0].params.directory).toBe("/test/project") + }) +}) + +describe("respondToQuestion passes directory", () => { + beforeEach(() => { + replyCalls.length = 0 + }) + + test("passes directory to question.reply", async () => { + const childStores = createChildStores([]) + + const { setActionRefs, respondToQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + await respondToQuestion("session-a", "q-1", [["answer1"]]) + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("q-1") + expect(replyCalls[0].params.directory).toBe("/test/project") + }) +}) + +describe("rejectQuestion passes directory", () => { + beforeEach(() => { + replyCalls.length = 0 + }) + + test("passes directory to question.reject", async () => { + const childStores = createChildStores([]) + + const { setActionRefs, rejectQuestion } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project") + + await rejectQuestion("session-a", "q-2") + + expect(replyCalls.length).toBe(1) + expect(replyCalls[0].params.requestID).toBe("q-2") + expect(replyCalls[0].params.directory).toBe("/test/project") + }) +}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 9d78638c..3019f585 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -12,6 +12,7 @@ import { opencodeClient } from "@/lib/opencode/client" import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" +import { isSyntheticPart } from "@/lib/messages/synthetic" // Reference set by SyncProvider — allows actions to access SDK and stores let _sdk: OpencodeClient | null = null @@ -443,9 +444,13 @@ export async function respondToPermission( response: "once" | "always" | "reject", ): Promise { await waitForConnectionOrThrow() + const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId) + || getSessionDirectory(sessionId) + || dir() const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, reply: response, + ...(directory ? { directory } : {}), }) if (!result.data) { throw new Error("Permission reply failed") @@ -457,9 +462,13 @@ export async function dismissPermission( requestId: string, ): Promise { await waitForConnectionOrThrow() + const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId) + || getSessionDirectory(sessionId) + || dir() const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, reply: "reject", + ...(directory ? { directory } : {}), }) if (!result.data) { throw new Error("Permission dismissal failed") @@ -476,9 +485,13 @@ export async function respondToQuestion( answers: string[] | string[][], ): Promise { await waitForConnectionOrThrow() + const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId) + || getSessionDirectory(sessionId) + || dir() const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({ requestID: requestId, answers: answers as Array>, + ...(directory ? { directory } : {}), }) if (!result.data) { throw new Error("Question reply failed") @@ -490,8 +503,12 @@ export async function rejectQuestion( requestId: string, ): Promise { await waitForConnectionOrThrow() + const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId) + || getSessionDirectory(sessionId) + || dir() const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({ requestID: requestId, + ...(directory ? { directory } : {}), }) if (!result.data) { throw new Error("Question rejection failed") @@ -525,13 +542,14 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro } } - // Extract message text for prompt restoration + // Extract message text for prompt restoration (only non-synthetic text parts — + // the server adds file content as synthetic text parts that should not be restored) const messages = state.message[sessionId] ?? [] const targetMsg = messages.find((m) => m.id === messageId) let messageText = "" if (targetMsg && targetMsg.role === "user") { const parts = state.part[messageId] ?? [] - const textParts = parts.filter((p) => p.type === "text") + const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p)) messageText = textParts .map((p: Record) => (p as { text?: string }).text || (p as { content?: string }).content || "") .join("\n") @@ -646,10 +664,11 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro const store = dirStore() const state = store.getState() - // Extract message text for input restoration + // Extract message text for input restoration (only non-synthetic text parts — + // the server adds file content as synthetic text parts that should not be restored) const parts = state.part[messageId] ?? [] let messageText = "" - const textParts = parts.filter((p) => p.type === "text") + const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p)) messageText = textParts .map((p: Part) => ((p as Record).text as string) || ((p as Record).content as string) || "") .join("\n") diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 86e8ccd9..67f1896b 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -358,6 +358,10 @@ export const useSessionUIStore = create()((set, get) => ({ } const previousSessionId = get().currentSessionId + + // Set currentSessionId immediately so the skeleton renders without delay. + set({ currentSessionId: id }) + const directoryState = useDirectoryStore.getState() const sessionDir = resolveSessionDirectory( @@ -376,20 +380,21 @@ export const useSessionUIStore = create()((set, get) => ({ console.warn("Failed to set OpenCode directory for session switch:", e) } - // Save viewport anchor for previous session + // Defer viewport anchor save for previous session — not needed for the + // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { - const memState = useViewportStore.getState().sessionMemoryState.get(previousSessionId) - if (!memState?.isStreaming) { - const prevMessages = getSyncMessages(previousSessionId) - if (prevMessages.length > 0) { - useViewportStore.getState().updateViewportAnchor(previousSessionId, prevMessages.length - 1) + const prevId = previousSessionId + setTimeout(() => { + const memState = useViewportStore.getState().sessionMemoryState.get(prevId) + if (!memState?.isStreaming) { + const prevMessages = getSyncMessages(prevId) + if (prevMessages.length > 0) { + useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1) + } } - } + }, 0) } - set({ currentSessionId: id }) - - // Mark session viewed in notification store + update active session ref // Mark session viewed in notification store + update active session ref if (id) { markSessionViewed(id) diff --git a/packages/ui/src/sync/session-worktree-contract.ts b/packages/ui/src/sync/session-worktree-contract.ts index 556a82f1..de00431c 100644 --- a/packages/ui/src/sync/session-worktree-contract.ts +++ b/packages/ui/src/sync/session-worktree-contract.ts @@ -169,12 +169,18 @@ export function getSessionWorktreeRepairActions( export type MutationBlockingReason = | { reason: 'attention'; attentionReason: NonNullable } | { reason: 'missing' } - | { reason: 'invalid' }; + | { reason: 'invalid' } + | { reason: 'dirty'; dirtyFiles?: number }; export function getMutationBlockingReasons( - attachment: SessionWorktreeAttachment | null | undefined + attachment: SessionWorktreeAttachment | null | undefined, + gitStatus?: { isClean?: boolean; files?: Array<{ path: string }> } ): MutationBlockingReason[] { const reasons: MutationBlockingReason[] = []; + if (gitStatus && gitStatus.isClean === false) { + const dirtyFiles = gitStatus.files?.length; + reasons.push(dirtyFiles != null ? { reason: 'dirty', dirtyFiles } : { reason: 'dirty' }); + } if (!attachment) return reasons; if (attachment.worktreeStatus === 'missing') { reasons.push({ reason: 'missing' }); diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 429350a7..de7972cb 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -21,4 +21,9 @@ declare module "bun:test" { toContain(expected: unknown): void; }; }; + export function beforeEach(fn: () => void | Promise): void; + export function mock unknown>(fn?: T): T; + export namespace mock { + function module(moduleName: string, factory: () => Record): void; + } } diff --git a/packages/web/index.html b/packages/web/index.html index b63b2fe9..92dcedef 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -2,6 +2,8 @@ + diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index d12e9eb8..b76cdff7 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pathLooksUserConfigured, mergePathValues } from './path-utils.js'; export const createOpenCodeEnvRuntime = (deps) => { const { @@ -162,21 +163,6 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; }; - const pathLooksUserConfigured = (value) => { - if (typeof value !== 'string' || !value) { - return false; - } - - const home = os.homedir(); - return value.split(path.delimiter).some((segment) => ( - segment.startsWith(home + path.sep) - || segment === home - || segment.startsWith('/opt/homebrew/') - || segment.startsWith('/opt/pkg/') - || segment.startsWith('/opt/pmk/') - )); - }; - const applyLoginShellEnvSnapshot = () => { const snapshot = getLoginShellEnvSnapshot(); if (!snapshot) { @@ -197,8 +183,9 @@ export const createOpenCodeEnvRuntime = (deps) => { const currentPath = process.env.PATH || ''; const shellPath = snapshot.PATH || ''; - if (!pathLooksUserConfigured(currentPath) && shellPath) { - process.env.PATH = shellPath; + const home = os.homedir(); + if (!pathLooksUserConfigured(currentPath, home, path.delimiter) && shellPath) { + process.env.PATH = mergePathValues(shellPath, currentPath, path.delimiter); } }; diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 129f854f..78864c60 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -116,4 +116,51 @@ describe('OpenCode lifecycle', () => { await server.close(); }); + + it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => { + delete process.env.OPENCODE_BINARY; + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); + }); + return child; + }); + + const runtime = createRuntime({ + buildManagedOpenCodePath: undefined, + buildAugmentedPath: vi.fn(() => '/home/user/.cargo/bin:/usr/local/bin'), + }); + const server = await runtime.startOpenCode(); + const [, , options] = spawnMock.mock.calls[0]; + + expect(options.env.PATH).toBe('/home/user/.cargo/bin:/usr/local/bin'); + + await server.close(); + }); + + it('falls back to process.env.PATH when neither build function is provided', async () => { + delete process.env.OPENCODE_BINARY; + const originalPath = process.env.PATH; + process.env.PATH = '/usr/bin:/bin'; + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); + }); + return child; + }); + + const runtime = createRuntime({ + buildManagedOpenCodePath: undefined, + buildAugmentedPath: undefined, + }); + const server = await runtime.startOpenCode(); + const [, , options] = spawnMock.mock.calls[0]; + + expect(options.env.PATH).toBe('/usr/bin:/bin'); + process.env.PATH = originalPath; + + await server.close(); + }); }); diff --git a/packages/web/server/lib/opencode/path-utils.js b/packages/web/server/lib/opencode/path-utils.js new file mode 100644 index 00000000..4803593a --- /dev/null +++ b/packages/web/server/lib/opencode/path-utils.js @@ -0,0 +1,100 @@ +/** + * Shared PATH heuristics and merge utilities for server and Electron runtimes. + * + * The heuristic decides whether the current process.env.PATH looks like it was + * configured by the user (or their session manager) vs. a minimal system default. + * When the PATH looks user-configured we keep it; otherwise we prefer the login + * shell PATH which typically has the full toolchain. + */ + +const TOOLCHAIN_SEGMENTS = [ + '/opt/homebrew/', + '/opt/pkg/', + '/opt/pmk/', + '/snap/', +]; + +const TOOLCHAIN_BASENAMES = new Set([ + '.cargo', + '.bun', + '.nvm', + '.pyenv', + '.rbenv', + '.sdkman', + '.asdf', + '.volta', + '.fnm', + '.local', + '.opencode', + 'node_modules', +]); + +/** + * Returns true when `value` (a PATH string) contains at least one segment that + * suggests the PATH was configured by the user or their session manager rather + * than being a bare system default. + * + * @param {string} value - The PATH string to inspect. + * @param {string} home - The user's home directory (os.homedir()). + * @param {string} delim - The PATH delimiter (':' on POSIX, ';' on Windows). + */ +export function pathLooksUserConfigured(value, home, delim) { + if (typeof value !== 'string' || !value) { + return false; + } + + const normalizedHome = typeof home === 'string' ? home.replaceAll('\\', '/') : ''; + const homeWithSep = normalizedHome ? normalizedHome + '/' : ''; + + return value.split(delim).some((segment) => { + if (!segment) return false; + const normalizedSegment = segment.replaceAll('\\', '/'); + + // Any path under the user's home directory. + if (normalizedHome && (normalizedSegment === normalizedHome || normalizedSegment.startsWith(homeWithSep))) { + return true; + } + + // Well-known package-manager / toolchain prefixes. + if (TOOLCHAIN_SEGMENTS.some((prefix) => normalizedSegment.startsWith(prefix))) { + return true; + } + + // Well-known dot-directories inside home (e.g. ~/.cargo/bin). + const parts = normalizedSegment.split('/').filter(Boolean); + if (parts.some((part) => TOOLCHAIN_BASENAMES.has(part))) { + return true; + } + + return false; + }); +} + +/** + * Merges two PATH strings, deduplicating segments while preserving the order of + * `primary` and appending any segments from `fallback` that are not already + * present. + * + * @param {string} primary - The preferred PATH (e.g. user-configured or login shell). + * @param {string} fallback - The secondary PATH to fill gaps from. + * @param {string} delim - The PATH delimiter. + */ +export function mergePathValues(primary, fallback, delim) { + const seen = new Set(); + const result = []; + + const addSegments = (value) => { + if (typeof value !== 'string' || !value) return; + for (const segment of value.split(delim)) { + if (segment && !seen.has(segment)) { + seen.add(segment); + result.push(segment); + } + } + }; + + addSegments(primary); + addSegments(fallback); + + return result.join(delim); +} diff --git a/packages/web/server/lib/opencode/path-utils.test.js b/packages/web/server/lib/opencode/path-utils.test.js new file mode 100644 index 00000000..4c75567d --- /dev/null +++ b/packages/web/server/lib/opencode/path-utils.test.js @@ -0,0 +1,71 @@ +import path from 'node:path'; +import os from 'node:os'; +import { describe, expect, it } from 'vitest'; + +import { pathLooksUserConfigured, mergePathValues } from './path-utils.js'; + +const home = os.homedir(); +const delim = path.delimiter; + +describe('pathLooksUserConfigured', () => { + it('returns false for empty or non-string values', () => { + expect(pathLooksUserConfigured('', home, delim)).toBe(false); + expect(pathLooksUserConfigured(null, home, delim)).toBe(false); + expect(pathLooksUserConfigured(undefined, home, delim)).toBe(false); + expect(pathLooksUserConfigured(42, home, delim)).toBe(false); + }); + + it('returns false for minimal system PATH', () => { + expect(pathLooksUserConfigured('/usr/local/bin:/usr/bin:/bin', home, delim)).toBe(false); + }); + + it('detects paths under home directory', () => { + expect(pathLooksUserConfigured(`${home}/.bun/bin:/usr/bin`, home, delim)).toBe(true); + expect(pathLooksUserConfigured(`${home}/.local/bin:/usr/bin`, home, delim)).toBe(true); + }); + + it('detects home directory itself', () => { + expect(pathLooksUserConfigured(`${home}:/usr/bin`, home, delim)).toBe(true); + }); + + it('detects well-known package manager prefixes', () => { + expect(pathLooksUserConfigured('/opt/homebrew/bin:/usr/bin', home, delim)).toBe(true); + expect(pathLooksUserConfigured('/opt/pkg/bin:/usr/bin', home, delim)).toBe(true); + expect(pathLooksUserConfigured('/snap/bin:/usr/bin', home, delim)).toBe(true); + }); + + it('detects well-known dot-directory basenames', () => { + expect(pathLooksUserConfigured('/some/path/.cargo/bin:/usr/bin', home, delim)).toBe(true); + expect(pathLooksUserConfigured('/some/path/.nvm/versions/node/v20/bin:/usr/bin', home, delim)).toBe(true); + expect(pathLooksUserConfigured('/some/path/.pyenv/shims:/usr/bin', home, delim)).toBe(true); + expect(pathLooksUserConfigured('/some/path/.opencode/bin:/usr/bin', home, delim)).toBe(true); + }); + + it('detects Windows home and toolchain paths', () => { + const windowsHome = 'C:\\Users\\agent'; + expect(pathLooksUserConfigured('C:\\Users\\agent\\.bun\\bin;C:\\Windows\\System32', windowsHome, ';')).toBe(true); + expect(pathLooksUserConfigured('C:\\tools\\.cargo\\bin;C:\\Windows\\System32', windowsHome, ';')).toBe(true); + }); +}); + +describe('mergePathValues', () => { + it('returns empty string for empty inputs', () => { + expect(mergePathValues('', '', delim)).toBe(''); + }); + + it('returns primary when fallback is empty', () => { + expect(mergePathValues('/a:/b', '', delim)).toBe('/a:/b'); + }); + + it('returns fallback when primary is empty', () => { + expect(mergePathValues('', '/a:/b', delim)).toBe('/a:/b'); + }); + + it('deduplicates segments, preserving primary order', () => { + expect(mergePathValues('/a:/b:/c', '/b:/d:/a', delim)).toBe('/a:/b:/c:/d'); + }); + + it('appends all fallback segments when no overlap', () => { + expect(mergePathValues('/a:/b', '/c:/d', delim)).toBe('/a:/b:/c:/d'); + }); +}); diff --git a/packages/web/server/lib/opencode/server-utils-runtime.js b/packages/web/server/lib/opencode/server-utils-runtime.js index 472a557f..267e6962 100644 --- a/packages/web/server/lib/opencode/server-utils-runtime.js +++ b/packages/web/server/lib/opencode/server-utils-runtime.js @@ -1,4 +1,5 @@ import { registerOpenCodeProxy } from './proxy.js'; +import { pathLooksUserConfigured, mergePathValues } from './path-utils.js'; export const createServerUtilsRuntime = (dependencies) => { const { @@ -45,21 +46,6 @@ export const createServerUtilsRuntime = (dependencies) => { clearLastOpenCodeError(); }; - const pathLooksUserConfigured = (value) => { - if (typeof value !== 'string' || !value) { - return false; - } - - const home = os.homedir(); - return value.split(path.delimiter).some((segment) => ( - segment.startsWith(home + path.sep) - || segment === home - || segment.startsWith('/opt/homebrew/') - || segment.startsWith('/opt/pkg/') - || segment.startsWith('/opt/pmk/') - )); - }; - const waitForOpenCodePort = async (timeoutMs = 15000) => { if (getOpenCodePort() !== null) { return getOpenCodePort(); @@ -77,40 +63,26 @@ export const createServerUtilsRuntime = (dependencies) => { }; const buildAugmentedPath = () => { - const home = os.homedir(); const currentPath = process.env.PATH || ''; const loginShellPath = getLoginShellPath(); - const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath); + const home = os.homedir(); + const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, home, path.delimiter); const primaryPath = currentPathLooksUserConfigured ? currentPath : loginShellPath; const fallbackPath = currentPathLooksUserConfigured ? loginShellPath : currentPath; - const seen = new Set(); - const augmented = []; - const addSegments = (value) => { - if (typeof value !== 'string' || !value) { - return; - } - for (const segment of value.split(path.delimiter)) { - if (segment && !seen.has(segment)) { - seen.add(segment); - augmented.push(segment); - } - } - }; - - addSegments(primaryPath); - addSegments(fallbackPath); - - return augmented.join(path.delimiter); + return mergePathValues(primaryPath, fallbackPath, path.delimiter); }; const buildManagedOpenCodePath = () => { const currentPath = process.env.PATH || ''; - if (pathLooksUserConfigured(currentPath)) { + const loginShellPath = getLoginShellPath(); + const home = os.homedir(); + + if (pathLooksUserConfigured(currentPath, home, path.delimiter)) { return currentPath; } - return getLoginShellPath() || currentPath; + return mergePathValues(loginShellPath || '', currentPath, path.delimiter); }; const parseSseDataPayload = (block) => { diff --git a/packages/web/server/lib/opencode/server-utils-runtime.test.js b/packages/web/server/lib/opencode/server-utils-runtime.test.js index 6bcaf829..bf34aacb 100644 --- a/packages/web/server/lib/opencode/server-utils-runtime.test.js +++ b/packages/web/server/lib/opencode/server-utils-runtime.test.js @@ -66,7 +66,15 @@ describe('server utils runtime', () => { const runtime = createRuntime(loginShellPath); - expect(runtime.buildManagedOpenCodePath()).toBe(loginShellPath); + // Should prefer login shell PATH but merge in any process entries not already present. + expect(runtime.buildManagedOpenCodePath()).toBe([ + path.join(home, '.opencode', 'bin'), + path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/bin', + '/usr/local/bin', + '/bin', + ].join(path.delimiter)); }); it('preserves user-configured process PATH order before appending shell-only entries', () => {