diff --git a/CHANGELOG.md b/CHANGELOG.md index d27d8310..c2260486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Work status: the session cost now counts what its subagents spent, with a line under the context meter splitting the session's own cost from the subagents' share, and each subagent's cost shown next to it in the Subagents list. Previously a session that delegated most of its work looked far cheaper than it was. +- Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). +- Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). ## [1.21.0] - 2026-08-26 @@ -26,6 +28,7 @@ All notable changes to this project will be documented in this file. - Search: every searchable picker uses one matcher now — best matches first, multi-word queries in any order, punctuation ignored ("gpt4o" finds "gpt-4o"). Ctrl/Cmd+P matches whole file paths. - Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. +- Chat: undoing or redoing a parent session now keeps its subagent sessions at the same point in history instead of leaving their later work behind (thanks to @alexandrereyes). - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. - Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it. @@ -89,6 +92,7 @@ All notable changes to this project will be documented in this file. - Git: pull-request checks in Work status stay current as their status changes. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). - Desktop/Windows: the close button now aligns correctly with the rest of the window chrome. +- Session assist: recaps and suggested follow-ups now work when the Anthropic provider is configured to use a custom endpoint; they previously failed every time instead of using that configured connection. ## [1.19.0] - 2026-08-19 @@ -116,6 +120,10 @@ All notable changes to this project will be documented in this file. - Desktop: browser pages served from a self-signed loopback HTTPS address now load instead of being blocked by the certificate warning. - Browser: typing a comment on a page no longer triggers app shortcuts. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). +- Chat: dismissing an agent's clarifying questions no longer leaves the session stuck on the question screen — the next task shows its thinking and final response again. +- VSCode: Add Project now adds the chosen folder to the workspace instead of showing a "Failed to add project" toast. +- UI: the model selection menu no longer shows white text on a white highlight when a high-contrast theme is active, so the hovered or selected model stays legible (thanks to @bashrusakh). +- Settings: an explicitly set `OPENCODE_BINARY` environment variable is no longer discarded when settings contain an empty opencodeBinary value; the environment variable keeps pointing the managed OpenCode server at the binary you chose. ## [1.18.4] - 2026-08-14 diff --git a/bun.lock b/bun.lock index b643770c..96ae8e04 100644 --- a/bun.lock +++ b/bun.lock @@ -191,6 +191,7 @@ "http-proxy-middleware": "^3.0.5", "katex": "^0.17.0", "marked": "^17.0.3", + "marked-linkify-it": "^4.0.2", "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", @@ -2431,7 +2432,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@6.1.0", "", { "dependencies": { "uc.micro": "^3.0.0" } }, "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -2487,6 +2488,8 @@ "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + "marked-linkify-it": ["marked-linkify-it@4.0.2", "", { "dependencies": { "linkify-it": "^6.1.0" }, "peerDependencies": { "marked": ">=4 <19" } }, "sha512-3nvMW0MHU+ZNBhzSnqRTl+tCkUwIBbg1xbHx5mtJqCH8ieJGLJ0JzV36ESnnufSgZ0mSwO22fBIeNEu5vvYd9w=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -3249,7 +3252,7 @@ "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], - "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "uc.micro": ["uc.micro@3.0.0", "", {}, "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -3659,6 +3662,10 @@ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "markdown-it/linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + + "markdown-it/uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromark-extension-math/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index b2ede00a..49c2ba22 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -33,6 +33,7 @@ import { } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; +import { createRendererRecoveryPolicy } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -2496,6 +2497,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }; const browserWindow = new BrowserWindow(options); + const rendererRecoveryPolicy = createRendererRecoveryPolicy(); browserWindow.__ocLabel = label || nextWindowLabel(); browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); @@ -2656,6 +2658,19 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } browserWindow.webContents.on('zoom-changed', () => { browserWindow.webContents.setZoomFactor(1); }); + browserWindow.webContents.on('render-process-gone', (_event, details) => { + if (!rendererRecoveryPolicy.shouldReload(details.reason)) return; + log.warn('[electron] renderer exited unexpectedly; reloading window', { + label: browserWindow.__ocLabel, + reason: details.reason, + exitCode: details.exitCode, + }); + setTimeout(() => { + if (!browserWindow.isDestroyed()) { + browserWindow.webContents.reload(); + } + }, 100); + }); browserWindow.webContents.on('dom-ready', () => { if (browserWindow.__ocLabel === 'main') { diff --git a/packages/electron/renderer-recovery.mjs b/packages/electron/renderer-recovery.mjs new file mode 100644 index 00000000..77037210 --- /dev/null +++ b/packages/electron/renderer-recovery.mjs @@ -0,0 +1,30 @@ +const RECOVERY_WINDOW_MS = 60_000; +const MAX_RECOVERY_ATTEMPTS = 3; + +const RECOVERABLE_REASONS = new Set([ + 'abnormal-exit', + 'crashed', + 'oom', + 'memory-eviction', +]); + +export const createRendererRecoveryPolicy = (now = Date.now) => { + let windowStartedAt = 0; + let attempts = 0; + + return { + shouldReload: (reason) => { + if (!RECOVERABLE_REASONS.has(reason)) return false; + + const currentTime = now(); + if (currentTime - windowStartedAt >= RECOVERY_WINDOW_MS) { + windowStartedAt = currentTime; + attempts = 0; + } + if (attempts >= MAX_RECOVERY_ATTEMPTS) return false; + + attempts += 1; + return true; + }, + }; +}; diff --git a/packages/electron/renderer-recovery.test.mjs b/packages/electron/renderer-recovery.test.mjs new file mode 100644 index 00000000..a0240051 --- /dev/null +++ b/packages/electron/renderer-recovery.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRendererRecoveryPolicy } from './renderer-recovery.mjs'; + +test('allows a bounded number of reloads for recoverable renderer failures', () => { + const policy = createRendererRecoveryPolicy(() => 1_000); + + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('oom'), true); + assert.equal(policy.shouldReload('abnormal-exit'), true); + assert.equal(policy.shouldReload('memory-eviction'), false); +}); + +test('ignores clean and externally killed renderer exits', () => { + const policy = createRendererRecoveryPolicy(() => 1_000); + + assert.equal(policy.shouldReload('clean-exit'), false); + assert.equal(policy.shouldReload('killed'), false); + assert.equal(policy.shouldReload('launch-failed'), false); +}); + +test('resets the recovery budget after the recovery window', () => { + let currentTime = 1_000; + const policy = createRendererRecoveryPolicy(() => currentTime); + + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), false); + + currentTime += 60_000; + assert.equal(policy.shouldReload('crashed'), true); +}); diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index dba5d6b5..4b21d928 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -1,17 +1,12 @@ - + + + + + + + + diff --git a/packages/ui/package.json b/packages/ui/package.json index f42fae11..6eeb6f0a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -67,6 +67,7 @@ "http-proxy-middleware": "^3.0.5", "katex": "^0.17.0", "marked": "^17.0.3", + "marked-linkify-it": "^4.0.2", "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9cd9e831..cf81988b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -7,6 +7,7 @@ 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 { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; // useEventStream removed — replaced by SyncProvider + SyncBridge import { useMenuActions } from '@/hooks/useMenuActions'; @@ -279,6 +280,13 @@ function App({ apis }: AppProps) { }; }, [showMemoryDebug]); + React.useEffect(() => { + setRequestsInFlightTrackingEnabled(showMemoryDebug); + return () => { + setRequestsInFlightTrackingEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { applyMobileKeyboardMode(mobileKeyboardMode); }, [mobileKeyboardMode]); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 5c4a5079..9178e36b 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -22,6 +22,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; import type { RuntimeAPIs } from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/projectContextApi'; import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device'; import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { useI18n } from '@/lib/i18n'; @@ -111,7 +112,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const [workspaceTab, setWorkspaceTab] = React.useState('changes'); // A plan opened from the workspace drawer's Notes tab, shown as a fullscreen // layer on top of it (back returns to the notes). - const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null); + const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string; projectRef: ProjectRef } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); @@ -542,7 +543,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc > { closeSurface(); closeWorkspace(); diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index f0847aa1..40a3649a 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -9,6 +9,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; import { TerminalView } from '@/components/views/TerminalView'; import { useI18n } from '@/lib/i18n'; +import type { ProjectRef } from '@/lib/projectContextApi'; import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMcpConfigStore } from '@/stores/useMcpConfigStore'; @@ -105,7 +106,7 @@ export const MobileWorkspaceDrawer: React.FC<{ /** When set, the Changes tab opens directly into the per-file diff. */ pendingChangesDiff: { path: string; staged: boolean } | null; /** Notes tab: opens a plan fullscreen (layered above the drawer). */ - onOpenPlan: (plan: { id: string; title: string }) => void; + onOpenPlan: (plan: { id: string; title: string; projectRef: ProjectRef }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; variant?: 'drawer' | 'panel'; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 1c5a40f1..5362e86d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -35,7 +35,8 @@ import { import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { BtwPanel } from './btw/BtwPanel'; import { useBtwPanelState } from './btw/useBtwPanelState'; -import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw'; +import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata'; +import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { ToolPopupContent } from './message/types'; @@ -104,10 +105,12 @@ import { type ComposerEditorHandle, } from './composer/editor/ComposerEditor'; import { createComposerEditorViewStore } from './composer/editor/viewStore'; +import { composerAutoCorrect } from './composer/editor/autocorrect'; import { appendInlineText, appendWithLineBreaks, buildImagePasteInsertion, + getMarkdownAutoPairEdit, shouldWrapSelectionAsLink, withInlineInsertionBoundaries, } from './composer/text'; @@ -338,6 +341,10 @@ const ChatInputComponent: React.FC = ({ [btwDirectory, btwSessionId, currentSessionId], ); const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed; + // A session promoted out of `/btw` keeps the boundary instructions in its + // transcript — there is no way to delete a message part — so it has to say + // they no longer apply. + const isPromotedBtwSession = wasPromotedBtwSession(btwPanel.parentSession); const activeRuntimeKey = getRuntimeKey(); const chatDraftIdentity = React.useMemo( () => createChatDraftIdentity( @@ -1010,6 +1017,7 @@ const ChatInputComponent: React.FC = ({ if (!providerIdToSend || !modelIdToSend) { console.warn('Cannot send message: provider or model not selected'); + toast.error(t('chat.chatInput.toast.noModelSelected')); return; } @@ -1126,7 +1134,14 @@ const ChatInputComponent: React.FC = ({ composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null, composerAttachments: attachedFiles, inlineComments: drafts, - syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], + // btw mode: the boundary rides with every send, not just the + // first one, so the inherited transcript stays reference material + // for the whole side conversation. + syntheticTexts: [ + ...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []), + ...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []), + ...(syntheticParts?.map((part) => part.text) ?? []), + ], linkedIssue: linkedIssue ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } : null, @@ -1620,39 +1635,18 @@ const ChatInputComponent: React.FC = ({ const selEnd = ta?.getSelection().end ?? -1; if (ta && selStart >= 0) { - const applyEdit = (next: string, caretStart: number, caretEnd: number) => { + const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd); + if (edit) { e.preventDefault(); - setMessage(next); - composerRef.current?.setSelection(caretStart, caretEnd); - updateAutocompleteState(next, caretEnd); - }; - - // Wrap the current selection: select text, press ` * _ ~ ( [ { " ' - const WRAP_PAIRS: Record = { - '`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'], - '(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'], - '"': ['"', '"'], "'": ["'", "'"], - }; - if (selEnd > selStart && WRAP_PAIRS[e.key]) { - const [open, close] = WRAP_PAIRS[e.key]; - const selected = message.slice(selStart, selEnd); - const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`; - applyEdit(next, selStart + open.length, selEnd + open.length); + ta.replaceRange( + edit.from, + edit.to, + edit.insert, + edit.selectionStart, + edit.selectionEnd, + ); return; } - - // Typing the third backtick at line start expands into a fenced - // code block with the caret on the empty middle line (Slack-like). - if (e.key === '`' && selStart === selEnd) { - const before = message.slice(0, selStart); - if (/(^|\n)``$/.test(before)) { - const after = message.slice(selEnd); - const next = `${before}\`\n\n\`\`\`${after}`; - const caret = before.length + 2; // after the completed ``` and first newline - applyEdit(next, caret, caret); - return; - } - } } } @@ -2851,7 +2845,7 @@ const ChatInputComponent: React.FC = ({ : t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') : t('chat.chatInput.placeholder.selectSession')} editable={Boolean(currentSessionId || newSessionDraftOpen)} - autoCorrect={isMobile} + autoCorrect={composerAutoCorrect({ isMobile })} autoCapitalize={isMobile ? 'sentences' : 'none'} spellCheck={isMobile || inputSpellcheckEnabled} fillContainer={isComposerExpanded} diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 20a86cde..8144b0a9 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole'; import { filterVisibleParts, normalizeParts } from './message/partUtils'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { isHiddenUserMessage } from './message/hiddenUserMessage'; -import { flattenAssistantTextParts } from '@/lib/messages/messageText'; +import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; import { getProviderModelDisplayName } from '@/lib/modelDisplay'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; @@ -702,40 +702,7 @@ const ChatMessage: React.FC = ({ const messageTextContent = React.useMemo(() => { if (isUser) { - const shellOutputs = displayParts - .filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text') - .map((part) => { - const output = part.shellAction?.output; - return typeof output === 'string' ? output.trim() : ''; - }) - .filter((output) => output.length > 0); - - if (shellOutputs.length > 0) { - return shellOutputs.join('\n\n'); - } - - const shellCommands = displayParts - .filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text') - .map((part) => { - const command = part.shellAction?.command; - return typeof command === 'string' ? command.trim() : ''; - }) - .filter((command) => command.length > 0); - - if (shellCommands.length > 0) { - return shellCommands.join('\n'); - } - - const textParts = displayParts - .filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text') - .map((part) => { - const text = part.text || part.content || ''; - return text.trim(); - }) - .filter((text) => text.length > 0); - - const combined = textParts.join('\n'); - return combined.replace(/\n\s*\n+/g, '\n'); + return flattenUserTextParts(displayParts); } if (assistantErrorText && assistantErrorText.trim().length > 0) { diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index c1cebccc..6cf85536 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessionMessages } from '@/sync/sync-context'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -66,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessionMessages = useSessionMessages(currentSessionId ?? ''); - const hasMessagesInCurrentSession = sessionMessages.length > 0; const hasSession = Boolean(currentSessionId); const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const canStartSessionCommand = hasSession || hasNewSessionDraft; @@ -140,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef commandMatchesSearch(cmd, searchQuery)) - : allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); + : allCommands; filtered.sort((a, b) => { const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase()); @@ -216,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef fuzzyMatch(cmd.name, searchQuery) || (cmd.description && fuzzyMatch(cmd.description, searchQuery)) ) - : builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); + : builtInCommands; setCommands(filtered); } finally { @@ -291,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef { setSelectedIndex(0); diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 00c0e80a..5f7f901d 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -47,8 +47,12 @@ export const MarkdownRenderer: React.FC ); -export const SimpleMarkdownRenderer: React.FC> = (props) => ( - }> +type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef & { + fallbackContent?: React.ReactNode; +}; + +export const SimpleMarkdownRenderer: React.FC = ({ fallbackContent, ...props }) => ( + }> ); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 4863a164..a3d918b2 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -2283,7 +2283,12 @@ export const ModelControls: React.FC = ({ : 'Default'; return ( - + Thinking: {displayLabel} ); diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index 01d5a5d0..14c0425e 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions'; import { useI18n } from '@/lib/i18n'; import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers'; import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing'; +import { QuestionMarkdown } from './QuestionMarkdown'; interface QuestionCardProps { question: QuestionRequest; @@ -423,7 +424,11 @@ export const QuestionCard: React.FC = ({ question }) => { ) : activeQuestion ? ( <> -
{activeQuestion.question}
+ {isMultiple ? (
{t('chat.questionCard.selectMultiple')}
diff --git a/packages/ui/src/components/chat/QuestionMarkdown.test.tsx b/packages/ui/src/components/chat/QuestionMarkdown.test.tsx new file mode 100644 index 00000000..2c3f1ef2 --- /dev/null +++ b/packages/ui/src/components/chat/QuestionMarkdown.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; + +import { SimpleMarkdownRenderer } from './MarkdownRenderer'; +import { QuestionMarkdown } from './QuestionMarkdown'; + +describe('QuestionMarkdown', () => { + test('delegates exact content to the tool markdown renderer', () => { + const content = 'Choose **one** from `mode`: [details](https://example.com)'; + const element = QuestionMarkdown({ content, size: 'meta' }); + + expect(element.type).toBe(SimpleMarkdownRenderer); + expect(element.props.content).toBe(content); + expect(element.props.variant).toBe('tool'); + expect(element.props.fallbackContent.props.children).toBe(content); + expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap'); + }); + + test('preserves question typography size and caller classes', () => { + const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' }); + const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' }); + + expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground'); + expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground'); + }); +}); diff --git a/packages/ui/src/components/chat/QuestionMarkdown.tsx b/packages/ui/src/components/chat/QuestionMarkdown.tsx new file mode 100644 index 00000000..3ffcdae7 --- /dev/null +++ b/packages/ui/src/components/chat/QuestionMarkdown.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { cn } from '@/lib/utils'; +import { SimpleMarkdownRenderer } from './MarkdownRenderer'; + +interface QuestionMarkdownProps { + content: string; + size: 'meta' | 'micro'; + className?: string; +} + +export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) { + const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className); + + return ( + {content}} + /> + ); +} diff --git a/packages/ui/src/components/chat/btw/useBtwPanelState.ts b/packages/ui/src/components/chat/btw/useBtwPanelState.ts index 6d36f060..9807e70e 100644 --- a/packages/ui/src/components/chat/btw/useBtwPanelState.ts +++ b/packages/ui/src/components/chat/btw/useBtwPanelState.ts @@ -5,6 +5,8 @@ import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetada import { useBtwStore } from '@/stores/useBtwStore'; export type BtwPanelState = { + /** The session the composer is in — the one `/btw` would fork. */ + parentSession: Session | null; /** The active fork for this parent, or null when no panel should exist. */ btwSessionId: string | null; btwSession: Session | null; @@ -40,6 +42,7 @@ export function useBtwPanelState( const destroying = Boolean(uiState?.destroying); const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null; return { + parentSession: parentSession ?? null, btwSessionId, btwSession: btwSessionId ? btwSession : null, // SAFETY: the SDK Session type omits the server's `directory` field; this diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 33066e13..a3d0854e 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -112,6 +112,14 @@ token: themes define `--interactive-selection` with its own alpha, so mixing it with transparent again is nearly invisible. The iOS system overlay owns its visible selection fill. +The content element keeps the existing correction policy: on in the mobile UI, +off elsewhere. CodeMirror also reads the attribute and reverts Apple and +Android's insert-period-on-double-space only when its value is exactly `off`. +`editor/autocorrect.ts` uses the HTML standard's +[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect) +to keep desktop word correction off while avoiding that CodeMirror-only +revert. Its platform checks deliberately match CodeMirror's own browser flags. + `composerLanguage.ts` retokenizes the whole document on every change. The composer holds a prompt, not a source file: it is short enough that a full pass is cheaper and far simpler than incremental mapping, and it keeps the editor diff --git a/packages/ui/src/components/chat/composer/__tests__/text.test.ts b/packages/ui/src/components/chat/composer/__tests__/text.test.ts index dd301062..beb51de5 100644 --- a/packages/ui/src/components/chat/composer/__tests__/text.test.ts +++ b/packages/ui/src/components/chat/composer/__tests__/text.test.ts @@ -4,6 +4,7 @@ import { appendInlineText, appendWithLineBreaks, buildImagePasteInsertion, + getMarkdownAutoPairEdit, shouldWrapSelectionAsLink, withInlineInsertionBoundaries, } from '../text'; @@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => { expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false); }); }); + +describe('getMarkdownAutoPairEdit', () => { + test('completes a fenced block with the caret on the middle line', () => { + expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({ + from: 2, + to: 2, + insert: '`\n\n```', + selectionStart: 4, + selectionEnd: 4, + }); + }); + + test('completes a fence at the start of any line', () => { + expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({ + from: 8, + to: 8, + insert: '`\n\n```', + selectionStart: 10, + selectionEnd: 10, + }); + }); + + test('does not complete two backticks in the middle of a line', () => { + expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull(); + }); + + test('wraps selected text and keeps the text selected', () => { + expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({ + from: 1, + to: 4, + insert: '*ell*', + selectionStart: 2, + selectionEnd: 5, + }); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 990f62ee..ad5372e7 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -34,6 +34,7 @@ import { import { cn } from '@/lib/utils'; import type { ComposerLanguageContext } from '../language/tokenize'; +import type { ComposerAutoCorrect } from './autocorrect'; import { composerLanguage, setLanguageContext } from './composerLanguage'; import type { ComposerEditorViewStore } from './viewStore'; import { composerEditorTheme, composerSelectionExtension } from './theme'; @@ -63,8 +64,8 @@ export interface ComposerEditorHandle { selectAll(): void; /** Replace the current selection, leaving the caret after the insertion. */ insertText(text: string): void; - /** Replace an explicit range; the caret lands at `caret` or after the text. */ - replaceRange(from: number, to: number, text: string, caret?: number): void; + /** Replace a range; selection defaults to a caret after the inserted text. */ + replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void; /** Viewport coordinates of the caret, for positioning popups. */ caretCoords(position?: number): { top: number; bottom: number; left: number } | null; /** The scrollable element, for measuring and scroll compensation. */ @@ -89,8 +90,11 @@ export interface ComposerEditorProps { placeholder?: string; editable?: boolean; spellCheck?: boolean; - /** Mobile keyboards; ignored on desktop. */ - autoCorrect?: boolean; + /** + * The content element's autocorrect keyword. See `autocorrect.ts` for the + * case-sensitive CodeMirror workaround. + */ + autoCorrect?: ComposerAutoCorrect; autoCapitalize?: 'none' | 'sentences'; /** Fill the available height instead of growing with the content. */ fillContainer?: boolean; @@ -157,7 +161,7 @@ export const ComposerEditor = React.forwardRef): Navigator => ({ + maxTouchPoints: 0, + platform: '', + userAgent: '', + vendor: '', + ...overrides, +} as Navigator); + +const codeMirrorKeepsDoubleSpacePeriod = ( + autoCorrect: ComposerAutoCorrect, +): boolean => autoCorrect !== 'off'; + +const affectedPlatforms: Array<[string, Navigator]> = [ + ['macOS', platform({ platform: 'MacIntel' })], + ['iPhone', platform({ + platform: 'iPhone', + userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1', + vendor: 'Apple Computer, Inc.', + })], + ['iPadOS touch detection', platform({ + maxTouchPoints: 5, + userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15', + vendor: 'Apple Computer, Inc.', + })], + ['Android', platform({ + platform: 'Linux armv8l', + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)', + })], +]; + +const unaffectedPlatforms: Array<[string, Navigator]> = [ + ['Windows', platform({ platform: 'Win32' })], + ['Linux', platform({ platform: 'Linux x86_64' })], +]; + +describe('composerAutoCorrect', () => { + test('matches the pinned CodeMirror period-revert guard', () => { + const source = readFileSync( + fileURLToPath(import.meta.resolve('@codemirror/view')), + 'utf8', + ); + const semantics = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\s+/g, ''); + + expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true); + expect(semantics).toContain( + 'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)', + ); + expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)'); + expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)'); + }); + + for (const [name, navigator] of affectedPlatforms) { + test(`preserves the ${name} platform period without enabling autocorrect`, () => { + const autoCorrect = composerAutoCorrect({ isMobile: false, navigator }); + + expect(autoCorrect.toLowerCase()).toBe('off'); + // @codemirror/view 6.39.13 reverts the native period only for exact "off". + expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true); + }); + } + + for (const [name, navigator] of unaffectedPlatforms) { + test(`leaves desktop correction off on ${name}`, () => { + expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off'); + }); + } + + test('uses CodeMirror platform detection rather than a macOS user agent', () => { + expect(composerAutoCorrect({ + isMobile: false, + navigator: platform({ + platform: 'Linux x86_64', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }), + })).toBe('off'); + }); + + test('preserves the existing mobile autocorrect policy', () => { + expect(composerAutoCorrect({ + isMobile: true, + navigator: platform({ platform: 'Win32' }), + })).toBe('on'); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/autocorrect.ts b/packages/ui/src/components/chat/composer/editor/autocorrect.ts new file mode 100644 index 00000000..7437407f --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/autocorrect.ts @@ -0,0 +1,24 @@ +export type ComposerAutoCorrect = 'on' | 'off' | 'Off'; + +type PlatformNavigator = Pick; + +/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */ +export function composerAutoCorrect(options: { + isMobile: boolean; + navigator?: PlatformNavigator; +}): ComposerAutoCorrect { + if (options.isMobile) return 'on'; + + const nav = options.navigator + ?? (typeof navigator === 'undefined' + ? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' } + : navigator); + // These must match CodeMirror's flags because its revert checks exact "off". + const ios = /Apple Computer/.test(nav.vendor) + && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2); + return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent) + ? 'Off' + : 'off'; +} diff --git a/packages/ui/src/components/chat/composer/editor/theme.ts b/packages/ui/src/components/chat/composer/editor/theme.ts index ccc9a933..80801782 100644 --- a/packages/ui/src/components/chat/composer/editor/theme.ts +++ b/packages/ui/src/components/chat/composer/editor/theme.ts @@ -20,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = { '&.cm-focused': { outline: 'none' }, '.cm-content': { padding: '0', + // Keep the drawn empty-document cursor inside the scroller's horizontal clip. + paddingInlineStart: '1px', fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit', diff --git a/packages/ui/src/components/chat/composer/text.ts b/packages/ui/src/components/chat/composer/text.ts index 6b84e650..58dc9d53 100644 --- a/packages/ui/src/components/chat/composer/text.ts +++ b/packages/ui/src/components/chat/composer/text.ts @@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea && selected.trim().length > 0 && !selected.includes(']('); } + +const MARKDOWN_WRAP_PAIRS: Record = { + '`': ['`', '`'], + '*': ['*', '*'], + '_': ['_', '_'], + '~': ['~', '~'], + '(': ['(', ')'], + '[': ['[', ']'], + '{': ['{', '}'], + '"': ['"', '"'], + "'": ["'", "'"], +}; + +/** + * Markdown source-mode conveniences handled before CodeMirror inserts a key. + * The returned text change and selection belong to one editor transaction so + * the caret cannot be applied against the previous document. + */ +export function getMarkdownAutoPairEdit( + value: string, + key: string, + selectionStart: number, + selectionEnd: number, +): { + from: number; + to: number; + insert: string; + selectionStart: number; + selectionEnd: number; +} | null { + const pair = MARKDOWN_WRAP_PAIRS[key]; + if (selectionEnd > selectionStart && pair) { + const selected = value.slice(selectionStart, selectionEnd); + const [open, close] = pair; + return { + from: selectionStart, + to: selectionEnd, + insert: `${open}${selected}${close}`, + selectionStart: selectionStart + open.length, + selectionEnd: selectionEnd + open.length, + }; + } + + if (key === '`' && selectionStart === selectionEnd) { + const before = value.slice(0, selectionStart); + if (/(^|\n)``$/.test(before)) { + return { + from: selectionStart, + to: selectionEnd, + insert: '`\n\n```', + selectionStart: selectionStart + 2, + selectionEnd: selectionStart + 2, + }; + } + } + + return null; +} diff --git a/packages/ui/src/components/chat/composerHighlight.ts b/packages/ui/src/components/chat/composerHighlight.ts index 04f29bee..8252fc26 100644 --- a/packages/ui/src/components/chat/composerHighlight.ts +++ b/packages/ui/src/components/chat/composerHighlight.ts @@ -103,7 +103,7 @@ const STYLE_CLASS: Record = { mentionAgent: 'text-[var(--status-success)]', mentionCommand: 'text-[var(--primary)]', mentionSnippet: 'text-[var(--status-warning)]', - code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]', + code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5', codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]', // A `~path` is written for the reader's benefit, not to attach anything — // it takes the same colour as a file mention, since it names the same kind diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 8981ebce..1ab6a6bd 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => { row.setAttribute('data-md-code-line', ''); const number = document.createElement('span'); - number.setAttribute('data-md-code-line-number', ''); + number.setAttribute('data-md-code-line-number', String(index + 1)); number.setAttribute('aria-hidden', 'true'); - number.textContent = String(index + 1); const content = document.createElement('span'); content.setAttribute('data-md-code-line-content', ''); @@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => { } else { content.textContent = sourceLine; } - row.append(number, content); fragment.appendChild(row); if (index < sourceLines.length - 1 || hasTrailingNewline) { @@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => { } }; +const getContainingMarkdownCode = (node: Node): HTMLElement | null => { + const element = node.nodeType === 1 ? node as Element : node.parentElement; + return element?.closest('pre code[data-md-code-lines]') ?? null; +}; + +const getMarkdownCodeSelectionText = (range: Range): string | null => { + const code = getContainingMarkdownCode(range.startContainer); + if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null; + // Line numbers are CSS-generated, so the DOM range is already the exact + // source selection, including boundaries between rows and empty lines. + return range.toString(); +}; + +type MarkdownCopyState = { + registrations: number; + handler: (event: ClipboardEvent) => void; + menuHandler: (event: Event) => void; +}; + +const markdownCopyStates = new WeakMap(); + +const registerMarkdownCodeCopy = (doc: Document): (() => void) => { + let state = markdownCopyStates.get(doc); + if (!state) { + const getSelectedText = (): string | null => { + const selection = doc.getSelection(); + if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; + return getMarkdownCodeSelectionText(selection.getRangeAt(0)); + }; + const handler = (event: ClipboardEvent) => { + if (!event.clipboardData) return; + const text = getSelectedText(); + if (text === null) return; + event.preventDefault(); + event.stopPropagation(); + event.clipboardData.setData('text/plain', text); + }; + const menuHandler = (event: Event) => { + const text = getSelectedText(); + if (text === null) return; + event.preventDefault(); + void copyTextToClipboard(text); + }; + state = { registrations: 0, handler, menuHandler }; + markdownCopyStates.set(doc, state); + doc.addEventListener('copy', handler, true); + doc.defaultView?.addEventListener('openchamber:copy', menuHandler); + } + state.registrations += 1; + + return () => { + const current = markdownCopyStates.get(doc); + if (!current) return; + current.registrations -= 1; + if (current.registrations > 0) return; + doc.removeEventListener('copy', current.handler, true); + doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler); + markdownCopyStates.delete(doc); + }; +}; + /** * Attach a single delegated click listener for all in-markdown actions: code * copy, table copy/download menus, mermaid copy/download, loopback preview. @@ -552,6 +611,7 @@ export const attachMarkdownInteractions = ( container: HTMLElement, ctx: DecorateContext, ): (() => void) => { + const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument); const handleClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; @@ -658,5 +718,8 @@ export const attachMarkdownInteractions = ( }; container.addEventListener('click', handleClick); - return () => container.removeEventListener('click', handleClick); + return () => { + unregisterCodeCopy(); + container.removeEventListener('click', handleClick); + }; }; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index e1321496..1b76c272 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -279,3 +279,30 @@ describe('Markdown images', () => { expect(html).not.toContain('data-openchamber-markdown-image'); }); }); + +describe('CJK-aware link parsing', () => { + const hrefOf = (html: string): string | null => /]*href="([^"]*)"/.exec(html)?.[1] ?? null; + + test('bare URL followed by a CJK annotation trims the annotation from the href', () => { + const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多'); + expect(hrefOf(html)).toBe('https://example.com/docs'); + }); + + test('bare URL followed by CJK punctuation trims the punctuation', () => { + expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe( + 'https://example.com/guide', + ); + expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com'); + }); + + test('correct links are unaffected', () => { + expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe( + 'https://docs.example.com', + ); + expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe( + 'https://dl.example.com/安装包(正式版)', + ); + expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)'); + expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url'); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 31c9af15..d9fee589 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -1,4 +1,5 @@ import { Marked, marked, type Tokens } from 'marked'; +import markedLinkifyIt from 'marked-linkify-it'; import remend from 'remend'; import katex from 'katex'; import DOMPurify from 'dompurify'; @@ -331,10 +332,15 @@ const blockMathExtension = { }, }; -const createParser = (imageMode: MarkdownImageMode) => new Marked().use({ - gfm: true, - breaks: false, - extensions: [inlineMathExtension, blockMathExtension], +// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch +// to marked-linkify-it, which treats Unicode punctuation as a URL boundary. +// Plain CJK characters right after a URL are still consumed, matching GitHub. +const createParser = (imageMode: MarkdownImageMode) => new Marked().use( + markedLinkifyIt({ fuzzyLink: false }), + { + gfm: true, + breaks: false, + extensions: [inlineMathExtension, blockMathExtension], renderer: { // Assistant output is untrusted. Markdown constructs still render as HTML, // but raw HTML must remain visible text so it cannot introduce active DOM diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts index 2faf34c3..d0697c59 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts +++ b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput'; import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; -import { tryParseJsonOutput } from '../toolRenderers'; +import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers'; import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle'; import { getToolDescriptionFallback } from './toolRenderUtils'; @@ -42,6 +42,29 @@ describe('getToolOutput', () => { }); }); +describe('parseDiffToUnified', () => { + test('handles a streamed diff with a bare Index header', () => { + expect(parseDiffToUnified('Index:')).toEqual([]); + expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([ + { + file: 'file', + oldStart: 1, + newStart: 1, + lines: [ + { type: 'removed', lineNumber: 1, content: 'old' }, + { type: 'added', lineNumber: 1, content: 'new' }, + ], + }, + ]); + }); + + test('preserves spaces when extracting the indexed filename', () => { + const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new'); + + expect(hunk?.file).toBe('my file.ts'); + }); +}); + describe('renderTerminalOutput', () => { test('renders carriage-return progress updates as their latest value', () => { expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%'); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index fa1279f9..78f6b41d 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -4,6 +4,7 @@ import { useMobileAppActions } from '@/apps/mobileAppContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; +import { QuestionMarkdown } from '../../QuestionMarkdown'; import { MessageFilesDisplay } from '../../FileAttachment'; import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; @@ -31,6 +32,7 @@ import { renderTodoOutput, tryParseJsonOutput, coerceToText, + capToolOutputText, } from '../toolRenderers'; import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer'; import { JsonSummaryView } from './JsonSummaryView'; @@ -605,11 +607,15 @@ const getToolOutputText = ( part: ToolPartType, metadata: Record | undefined, ): string => { + // Cap oversized payloads before JSON.parse / syntax highlighting / DOM work + // so a single huge tool output can't trigger a V8 Zone-allocation OOM that + // hard-crashes the renderer (issue #2265). + const capped = capToolOutputText(output); if (part.tool === 'bash') { - return output; + return capped; } - return formatEditOutput(output, part.tool, metadata); + return formatEditOutput(capped, part.tool, metadata); }; const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => { @@ -1407,7 +1413,7 @@ const ToolExpandedContent: React.FC = React.memo(({
{parsedQA.map((qa, index) => (
-
{qa.question}
+
{qa.answer}
))} @@ -1444,7 +1450,7 @@ const ToolExpandedContent: React.FC = React.memo(({ {q.header ? (
{coerceToText(q.header)}
) : null} -
{coerceToText(q.question)}
+ {Array.isArray(q.options) && q.options.length > 0 ? (
{q.options.map((opt) => ( @@ -1965,6 +1971,7 @@ const ToolPartContent: React.FC = ({ return null; }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); + const mobileActions = useMobileAppActions(); const openApplyPatchFile = (file: Record, event: React.MouseEvent) => { if (!runtime?.editor) { @@ -2037,6 +2044,61 @@ const ToolPartContent: React.FC = ({ handleMainClick(event); }; + // Quick-open target for the file-link icon in the tool header. Resolves the + // primary file path (and, for diff tools, the first changed line + diff) so + // the user can open the file in the side panel (web/desktop) or editor + // (VS Code) without expanding the tool card. Reuses the same path helpers as + // handleMainClick above; the difference is the web fallback — handleMainClick + // only opens when runtime.editor is available, this icon also falls back to + // useUIStore.openContextFile{AtLine} so the file opens in the right pane. + const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => { + if (isTaskTool) return null; + const toolName = normalizedPartTool || part.tool; + const filePath = getPrimaryToolPath(toolName, input, metadata); + if (typeof filePath !== 'string') return null; + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + let line: number | undefined; + let toolDiff: string | undefined; + if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') { + line = getFirstChangedLineFromMetadata(toolName, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath); + } + return { absolutePath, line, toolDiff, toolName }; + }, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]); + + const openQuickTarget = () => { + if (!quickOpenTarget) return; + const { absolutePath, line, toolDiff, toolName } = quickOpenTarget; + if (runtime?.editor) { + if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) { + const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; + void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff }); + return; + } + runtime.editor.openFile(absolutePath, line); + return; + } + const uiStore = useUIStore.getState(); + if (typeof line === 'number' && Number.isFinite(line)) { + uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1); + } else { + uiStore.openContextFile(currentDirectory, absolutePath); + } + mobileActions?.openFiles(); + }; + + const handleQuickOpen = (event: React.MouseEvent) => { + event.stopPropagation(); + openQuickTarget(); + }; + + const handleQuickOpenKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + event.stopPropagation(); + openQuickTarget(); + }; + const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE; const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE; const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId)); @@ -2130,7 +2192,7 @@ const ToolPartContent: React.FC = ({ {isExpanded ? : }
-
+
= ({ > {displayName} + {quickOpenTarget ? ( + + ) : null}
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? ( diff --git a/packages/ui/src/components/chat/message/toolRenderers.test.ts b/packages/ui/src/components/chat/message/toolRenderers.test.ts new file mode 100644 index 00000000..142ed4ca --- /dev/null +++ b/packages/ui/src/components/chat/message/toolRenderers.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect } from 'bun:test'; + +import { capToolOutputText, TOOL_OUTPUT_MAX_CHARS } from './toolRenderers'; + +// Regression coverage for issue #2265: the desktop renderer hard-crashes with a +// V8 "Zone Allocation failed" OOM when a tool returns oversized external content +// (e.g. a fetched Google Slides page with full-resolution base64 images inlined), +// because the whole payload previously flowed through JSON.parse / syntax +// highlighting / DOM rendering as a single unbounded JS string. capToolOutputText +// is the bounded size guard that runs before any of that work. +describe('capToolOutputText (issue #2265 renderer OOM guard)', () => { + test('exposes a sane positive default cap', () => { + expect(typeof TOOL_OUTPUT_MAX_CHARS).toBe('number'); + expect(TOOL_OUTPUT_MAX_CHARS).toBeGreaterThan(0); + }); + + test('returns short output unchanged', () => { + const output = 'hello world'; + expect(capToolOutputText(output)).toBe(output); + }); + + test('returns output at exactly the cap unchanged', () => { + const output = 'a'.repeat(TOOL_OUTPUT_MAX_CHARS); + expect(capToolOutputText(output)).toBe(output); + expect(capToolOutputText(output).length).toBe(TOOL_OUTPUT_MAX_CHARS); + }); + + test('caps oversized output and never emits the full string', () => { + const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 10_000); + const capped = capToolOutputText(oversized); + + // The pathological full-size string must not survive to the renderer. + expect(capped.length).toBeLessThan(oversized.length); + // Head of the payload is preserved for the user. + expect(capped.startsWith('x'.repeat(1000))).toBe(true); + // A truncation notice is appended so the truncation is visible. + expect(capped).toContain('output truncated'); + expect(capped).toContain('10000 more characters'); + }); + + test('honors a custom cap', () => { + const output = 'abcdefghij'; // 10 chars + const capped = capToolOutputText(output, 4); + expect(capped.startsWith('abcd')).toBe(true); + expect(capped).toContain('output truncated'); + // Only the first 4 chars of the original body are retained. + expect(capped).not.toContain('efghij'); + }); + + test('simulated large webfetch payload is bounded well below original size', () => { + // ~6MB single string, matching the 5MB-20MB Zone-allocation trigger range + // described in the issue (a Slides page with embedded base64 images). + const base64Blob = 'QUJD'.repeat(1_500_000); // 6,000,000 chars + const capped = capToolOutputText(base64Blob); + + expect(base64Blob.length).toBeGreaterThan(5_000_000); + expect(capped.length).toBeLessThan(TOOL_OUTPUT_MAX_CHARS + 256); + expect(capped).toContain('renderer from running out of memory'); + }); + + test('non-string input is returned unchanged (defensive)', () => { + // @ts-expect-error verifying runtime robustness against non-string inputs + expect(capToolOutputText(undefined)).toBeUndefined(); + // @ts-expect-error verifying runtime robustness against non-string inputs + expect(capToolOutputText(null)).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index 3c4dd773..8e600d3f 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -22,6 +22,28 @@ export const coerceToText = (value: unknown, fallback = ''): string => { } }; +// Guards the renderer process against V8 "Zone Allocation failed" OOM crashes +// (issue #2265). When a tool returns oversized external content — e.g. a fetched +// web page with full-resolution base64 images inlined — the entire payload flows +// through this module as a single JS string that is JSON.parsed, syntax +// highlighted, and attached to the DOM. A large enough single string exceeds +// V8's Zone allocator and hard-crashes the renderer before any virtualization or +// CSS clip can help. Capping the string length before that work happens keeps a +// useful head of the output while preventing the pathological allocation. +export const TOOL_OUTPUT_MAX_CHARS = 512 * 1024; + +export const capToolOutputText = ( + output: string, + maxChars: number = TOOL_OUTPUT_MAX_CHARS, +): string => { + if (typeof output !== 'string' || output.length <= maxChars) { + return output; + } + const omitted = output.length - maxChars; + const notice = `\n\n… [output truncated: ${omitted} more characters not shown to prevent the renderer from running out of memory]`; + return output.slice(0, maxChars) + notice; +}; + const hasLspDiagnostics = (output: string): boolean => { if (!output) return false; return output.includes(' { if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) { if (line.startsWith('Index:')) { - currentFile = line.split(' ')[1].split('/').pop() || 'file'; + currentFile = line.slice('Index:'.length).trim().split('/').pop() || 'file'; } i++; continue; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index aa52be5d..cd22371e 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -943,7 +943,12 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : null; const browserTabs = React.useMemo( diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 8138bef1..3907a341 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -6,63 +6,53 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { useProjectContextOwner } from '@/hooks/useProjectContextOwner'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; +import type { ProjectRef } from '@/lib/projectContextApi'; import { useI18n } from '@/lib/i18n'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; - onOpenPlan?: (plan: { id: string; title: string }) => void; + onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void; }> = ({ onActionComplete, onOpenPlan }) => { - const activeProjectId = useProjectsStore((state) => state.activeProjectId); - const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { t } = useI18n(); const gitDirectories = useGitStore((state) => state.directories); - const isChatContext = useSessionUIStore((state) => ( - state.newSessionDraft.open - ? state.newSessionDraft.target === 'chat' - : isChatDirectoryPath(state.currentSessionDirectory) - )); const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); - const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory); - const activeProject = React.useMemo(() => { - if (isChatContext) return null; - if (activeProjectId) { - return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; - } - return projects[0] ?? null; - }, [activeProjectId, isChatContext, projects]); + // One owner decision shared with the panel, agent memory, and PlanView: + // chats resolve to the Chats owner, worktrees to their project, and an + // unrecognized directory owns nothing (null) rather than borrowing + // whichever project happens to be active. + const projectRef = useProjectContextOwner(chatSessionDirectory); - const projectRef = React.useMemo(() => { - if (isChatContext && chatsRoot) { - return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; - } - if (!activeProject) { - return null; - } - return { - id: activeProject.id, - path: activeProject.path, - }; - }, [activeProject, chatsRoot, isChatContext]); + // Display-only lookup: a user-renamed project label wins over the directory + // name. The owner decision stays with the hook — this must not reintroduce + // a fallback. + const projects = useProjectsStore((state) => state.projects); + const labeledProject = React.useMemo( + () => (projectRef ? projects.find((project) => project.id === projectRef.id) ?? null : null), + [projectRef, projects], + ); const projectLabel = React.useMemo(() => { - if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); - if (!activeProject) { + if (!projectRef) { return null; } - return activeProject.label?.trim() - || formatDirectoryName(activeProject.path, homeDirectory) - || activeProject.path; - }, [activeProject, homeDirectory, isChatContext, t]); + if (projectRef.id === CHAT_DRAFT_PROJECT_ID) { + return t('sessions.sidebar.activity.chatsTitle'); + } + return labeledProject?.label?.trim() + || formatDirectoryName(projectRef.path, homeDirectory) + || projectRef.path; + }, [homeDirectory, labeledProject, projectRef, t]); const canCreateWorktree = React.useMemo(() => { - if (!activeProject) { + if (!projectRef || projectRef.id === CHAT_DRAFT_PROJECT_ID) { return false; } - return gitDirectories.get(activeProject.path)?.isGitRepo === true; - }, [activeProject, gitDirectories]); + return gitDirectories.get(projectRef.path)?.isGitRepo === true; + }, [gitDirectories, projectRef]); return ( /* The panel scrolls its own tab content; a scroller here would nest. */ diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index 6d09dfc1..2a96c03f 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -690,7 +690,9 @@ export const ModelPickerList: React.FC = ({ onMouseMove={handleMouseActivity} className={cn( 'w-full text-left px-2 py-1.5 rounded-md typography-meta flex items-center gap-2 cursor-pointer', - !disabled && (isHighlighted ? 'bg-interactive-selection' : 'hover:bg-interactive-hover/50'), + !disabled && (isHighlighted + ? 'bg-interactive-selection text-interactive-selection-foreground' + : 'hover:bg-interactive-hover/50'), disabled && 'cursor-not-allowed opacity-60', rowClassName, )} @@ -703,9 +705,9 @@ export const ModelPickerList: React.FC = ({ ) : null} {showProviderLogo ? : null} {getModelDisplayName(entry.model)} - {contextTokens ? {contextTokens} : null} + {contextTokens ? {contextTokens} : null}
- {count > 0 ? x{count} : null} + {count > 0 ? x{count} : null} {renderRowEnd?.(entry, { isHighlighted, isSelected })} {isSelected ? : null} {onToggleFavorite ? ( diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 54716085..aff0ff7d 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -32,7 +32,6 @@ import { startDesktopWindowDrag } from '@/lib/desktopNative'; import { useI18n } from '@/lib/i18n'; const MAX_FILE_SIZE = 10 * 1024 * 1024; -const MAX_MODELS_PER_GROUP = 5; interface MultiRunAttachedFile { id: string; @@ -727,7 +726,6 @@ const RunGroupCard: React.FC = ({ const snippetRef = React.useRef(null); const handleAddModel = React.useCallback((model: ModelSelectionWithId) => { - if (group.models.length >= MAX_MODELS_PER_GROUP) return; onUpdate(group.id, { models: [...group.models, model] }); }, [group.id, group.models, onUpdate]); @@ -987,7 +985,7 @@ const RunGroupCard: React.FC = ({
{t('multirun.launcher.models.info', { max: MAX_MODELS_PER_GROUP })}} + info={{t('multirun.launcher.models.info')}} > {t('multirun.launcher.models.label')} @@ -997,7 +995,6 @@ const RunGroupCard: React.FC = ({ onRemove={handleRemoveModel} onUpdate={handleUpdateModel} minModels={1} - maxModels={MAX_MODELS_PER_GROUP} />
diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.test.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.test.tsx new file mode 100644 index 00000000..469fc0ab --- /dev/null +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.test.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import { describe, expect, mock, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +type ClickEvent = { stopPropagation: () => void }; +type ClickHandler = (event: ClickEvent) => void; +type ChildrenProps = { children?: React.ReactNode }; +type ClickableProps = ChildrenProps & { onClick?: ClickHandler }; +type TriggerProps = ChildrenProps & { render?: React.ReactNode }; + +interface AgentDraftSnapshot { + name: string; + scope: string; + description?: string; + model?: string | null; + variant?: string; + temperature?: number; + top_p?: number; + prompt?: string; + mode?: string; + permission?: Record; + disable?: boolean; +} + +interface AgentRecord { + name: string; + description: string; + model: { providerID: string; modelID: string }; + variant: string; + temperature: number; + topP: number; + prompt: string; + mode: string; + permission: Array<{ permission: string; pattern: string; action: 'allow' | 'ask' | 'deny' }>; + scope: string; + disable: boolean; +} + +interface AgentStoreState { + selectedAgentName: string | null; + agents: AgentRecord[]; + setAgentDraft: (draft: AgentDraftSnapshot) => void; + setSelectedAgent: (name: string) => void; + createAgent: () => Promise<{ ok: boolean }>; + deleteAgent: () => Promise<{ ok: boolean }>; + loadAgents: () => Promise; +} + +const sourceAgent: AgentRecord = { + name: 'writer', + description: 'Writes concise documentation', + model: { providerID: 'openai', modelID: 'gpt-4.1' }, + variant: 'fast', + temperature: 0.4, + topP: 0.8, + prompt: 'Write clear documentation.', + mode: 'subagent', + permission: [{ permission: 'bash', pattern: '*', action: 'ask' }], + scope: 'project', + disable: true, +}; + +let recordedDraft: AgentDraftSnapshot | null = null; +let selectedAgentName: string | null = null; +let duplicateMenuClick: ClickHandler | null = null; +let mobileDevice = true; + +const agentStore: AgentStoreState = { + selectedAgentName: null, + agents: [sourceAgent], + setAgentDraft: (draft) => { + recordedDraft = draft; + }, + setSelectedAgent: (name) => { + selectedAgentName = name; + }, + createAgent: async () => ({ ok: true }), + deleteAgent: async () => ({ ok: true }), + loadAgents: async () => {}, +}; + +function useAgentsStore(selector: (state: AgentStoreState) => Selected): Selected { + return selector(agentStore); +} + +function useShallow(selector: Selector): Selector { + return selector; +} + +mock.module('@/components/ui/button', () => ({ + Button: ({ children, onClick }: ClickableProps) => , +})); + +mock.module('@/components/ui/input', () => ({ + Input: () => , +})); + +mock.module('@/components/ui', () => ({ + toast: { error: () => {}, success: () => {}, warning: () => {} }, +})); + +mock.module('@/lib/device', () => ({ + isMobileDeviceViaCSS: () => mobileDevice, +})); + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children }: ChildrenProps) => <>{children}, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogDescription: ({ children }: ChildrenProps) =>
{children}
, + DialogFooter: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})); + +mock.module('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: ChildrenProps) => <>{children}, + DropdownMenuContent: ({ children }: ChildrenProps) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: ClickableProps) => { + if (React.Children.toArray(children).includes('Duplicate')) { + duplicateMenuClick = onClick ?? null; + } + return ; + }, + DropdownMenuTrigger: ({ children }: ChildrenProps) => <>{children}, +})); + +mock.module('@/components/ui/context-menu', () => ({ + ContextMenu: ({ children }: ChildrenProps) => <>{children}, + ContextMenuContent: ({ children }: ChildrenProps) =>
{children}
, + ContextMenuItem: ({ children }: ChildrenProps) =>
{children}
, + ContextMenuTrigger: ({ children, render }: TriggerProps) => <>{render}{children}, +})); + +mock.module('@/hooks/useSettingsDirectory', () => ({ + useSettingsDirectory: () => '/workspace', +})); + +mock.module('@/stores/useAgentsStore', () => ({ + useAgentsStore, + selectAgentsForDirectory: (state: AgentStoreState) => state.agents, + isAgentBuiltIn: () => false, + isAgentHidden: () => false, +})); + +mock.module('zustand/react/shallow', () => ({ useShallow })); + +mock.module('@/lib/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +mock.module('@/components/ui/ScrollableOverlay', () => ({ + ScrollableOverlay: ({ children }: ChildrenProps) =>
{children}
, +})); + +mock.module('@/components/sections/shared/SettingsProjectSelector', () => ({ + SettingsProjectSelector: () => null, +})); + +mock.module('@/components/sections/shared/SidebarGroup', () => ({ + SidebarGroup: ({ children }: ChildrenProps) => <>{children}, +})); + +mock.module('@/components/icon/Icon', () => ({ + Icon: () => null, +})); + +mock.module('@/lib/i18n', () => ({ + useI18n: () => ({ + t: (key: string) => (key === 'settings.common.actions.duplicate' ? 'Duplicate' : key), + }), +})); + +mock.module('@/components/sections/shared/SettingsSection', () => ({ + SETTINGS_PANEL_TITLE_CLASS: '', +})); + +const { AgentsSidebar } = await import('./AgentsSidebar'); + +function getDuplicateMenuClick(): ClickHandler { + if (!duplicateMenuClick) { + throw new Error('Expected the duplicate action to be rendered'); + } + return duplicateMenuClick; +} + +describe('AgentsSidebar duplicate action', () => { + test('notifies the mobile split-view parent once after preparing a prefilled agent draft', () => { + recordedDraft = null; + selectedAgentName = null; + duplicateMenuClick = null; + mobileDevice = true; + let mobileTransitionCount = 0; + + renderToStaticMarkup( + { mobileTransitionCount += 1; }} />, + ); + + getDuplicateMenuClick()({ stopPropagation: () => {} }); + + expect(recordedDraft).toEqual({ + name: 'writer-copy', + scope: 'project', + description: 'Writes concise documentation', + model: 'openai/gpt-4.1', + variant: 'fast', + temperature: 0.4, + top_p: 0.8, + prompt: 'Write clear documentation.', + mode: 'subagent', + permission: { bash: 'ask' }, + disable: true, + }); + expect(selectedAgentName).toBe('writer-copy'); + expect(mobileTransitionCount).toBe(1); + }); + + test('does not require a mobile transition callback on desktop', () => { + recordedDraft = null; + selectedAgentName = null; + duplicateMenuClick = null; + mobileDevice = false; + + renderToStaticMarkup(); + + getDuplicateMenuClick()({ stopPropagation: () => {} }); + expect(selectedAgentName).toBe('writer-copy'); + }); +}); diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 5009b623..dce4c5c9 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -250,6 +250,7 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => disable: draftAgent.disable, }); setSelectedAgent(newName); + onItemSelect?.(); }; diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx new file mode 100644 index 00000000..36ac2368 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n"; +import { useGitHubAuthStore } from "@/stores/useGitHubAuthStore"; + +import { GitHubSettings } from "./GitHubSettings"; + +const serverAuthState = useGitHubAuthStore.getInitialState(); + +const resetServerAuthState = () => { + Object.assign(serverAuthState, { + status: null, + isLoading: false, + hasChecked: false, + }); +}; + +const renderSettings = () => + renderToStaticMarkup( + + + , + ); + +describe("GitHubSettings", () => { + beforeEach(resetServerAuthState); + afterEach(resetServerAuthState); + + test("stays hidden during the initial auth status load", () => { + serverAuthState.isLoading = true; + + expect(renderSettings()).toBe(""); + }); + + test("stays mounted while a checked status is refreshing, then shows reconnect state", () => { + Object.assign(serverAuthState, { + status: { + connected: true, + user: { login: "octocat" }, + }, + isLoading: true, + hasChecked: true, + }); + + const refreshingMarkup = renderSettings(); + expect(refreshingMarkup).toContain("octocat"); + expect(refreshingMarkup).toContain("Disconnect"); + + Object.assign(serverAuthState, { + status: { connected: false }, + isLoading: false, + hasChecked: true, + }); + + const disconnectedMarkup = renderSettings(); + expect(disconnectedMarkup).toContain("Not Connected"); + expect(disconnectedMarkup).toContain("Connect GitHub"); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index a9500d45..e2a06bda 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -256,7 +256,7 @@ export const GitHubSettings: React.FC = () => { } }, [runtimeGitHub, setStatus, t]); - if (isLoading) { + if (isLoading && !hasChecked) { return null; } diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index e03855d6..7294a1f4 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -205,7 +205,7 @@ const getFallbackInstallCommand = (provider: string, platform = getClientInstall if (platform === 'darwin') { return 'brew install cloudflared'; } - return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/'; + return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/'; }; const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => { diff --git a/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx index 7cc99d59..2916fc92 100644 --- a/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx +++ b/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx @@ -75,13 +75,13 @@ export const SettingsPageLayout: React.FC = ({ hasTitleChrome ? (
{titleLeading} -

{title}

+

{title}

{/* A status badge carries a fixed word; compressing it wraps the text inside its own pill. */} {titleAccessory}
) : ( -

{title}

+

{title}

) ) : ( title diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 1700286e..90f4f370 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -359,7 +359,7 @@ export const DirectoryExplorerDialog: React.FC = ( const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0; const highlightedRow = rows[highlightedIndex] ?? null; const hasHighlightedBrowseItem = Boolean( - highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled)) + highlightedRow && (highlightedRow.type === 'up' || highlightedRow.type === 'directory') ); const submitModifierLabel = formatShortcutForDisplay('mod'); const submitActionLabel = isAlreadyAdded @@ -414,11 +414,11 @@ export const DirectoryExplorerDialog: React.FC = ( handleClose(); }, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]); - const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => { + const handleQuickAdd = React.useCallback(async (event: React.MouseEvent, path: string) => { event.stopPropagation(); const normalized = normalizeDirectoryPath(path); if (normalized && addedProjectPaths.has(normalized)) return; - const project = addProject(path); + const project = await addProject(path); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), @@ -452,7 +452,7 @@ export const DirectoryExplorerDialog: React.FC = ( } else if (shouldCreateSelection) { await opencodeClient.createDirectory(target, { asProject: true }); } - const project = addProject(selectedTarget); + const project = await addProject(selectedTarget); if (!project) { toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), { description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'), @@ -483,7 +483,6 @@ export const DirectoryExplorerDialog: React.FC = ( if (row.path) browseToDisplayPath(row.path); return; } - if (row.disabled) return; browseToEntry(row); }, [browseToDisplayPath, browseToEntry]); @@ -662,7 +661,6 @@ export const DirectoryExplorerDialog: React.FC = ( } }} type="button" - disabled={row.type === 'directory' && row.disabled} onMouseEnter={() => setHighlightedIndex(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => executeRow(row)} @@ -670,7 +668,7 @@ export const DirectoryExplorerDialog: React.FC = ( 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', isActive && 'bg-interactive-selection text-interactive-selection-foreground', !isActive && 'hover:bg-interactive-hover/50', - row.type === 'directory' && row.disabled && 'cursor-not-allowed opacity-45 hover:bg-transparent' + row.type === 'directory' && row.disabled && 'opacity-45' )} > {row.type === 'up' ? ( diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 5fc9f2c3..2912c7d8 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1207,10 +1207,10 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1239,10 +1239,10 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -1466,10 +1466,10 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -1493,10 +1493,10 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -1675,10 +1675,9 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> - {hasExistingBranchQuery && } - + {existingBranchRankedGroups.otherLocal.map((branch) => ( )} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( <> - {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( )} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( <> - {hasSourceBranchQuery && } - + {sourceBranchRankedGroups.otherLocal.map((branch) => ( )} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( <> - {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => (