From 39bb71a62baa4e951190f89cf09deea892ed99de Mon Sep 17 00:00:00 2001 From: herjarsa Date: Mon, 17 Aug 2026 11:37:20 +0200 Subject: [PATCH 001/114] fix(ui): bound OpenCode read requests so half-open sockets cannot freeze bootstrap (#2470) The SDK client fetch wrapper now applies a 30s timeout to non-streaming reads. Without it, a socket that neither resolves nor rejects keeps the directory bootstrap concurrency slot busy forever and the UI stays on "loading sessions". Long-lived streams (POST prompts, the /event SSE) are explicitly excluded so they are not cut off mid-flight. The normalized "request timed out" error is added to the retry allowlist alongside undici's "terminated" (the exact failure observed in #2470 when undici tears down a half-open upstream connection); both are transient while the managed OpenCode process restarts. Caller- initiated aborts keep their original error shape so a user-cancelled request is not retried. Tests cover: GET timeout fires after the bound, POST is not timed out, /event SSE is not timed out, caller abort wins, AbortError is not retried, and the SDK normalized error is retried 3x. --- .../src/lib/opencode/client-timeout.test.ts | 157 ++++++++++++++++++ packages/ui/src/lib/opencode/client.ts | 55 +++++- packages/ui/src/sync/retry.test.ts | 38 +++++ packages/ui/src/sync/retry.ts | 6 + packages/ui/src/types/bun-test.d.ts | 11 +- 5 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/opencode/client-timeout.test.ts create mode 100644 packages/ui/src/sync/retry.test.ts diff --git a/packages/ui/src/lib/opencode/client-timeout.test.ts b/packages/ui/src/lib/opencode/client-timeout.test.ts new file mode 100644 index 00000000..fd45a4a7 --- /dev/null +++ b/packages/ui/src/lib/opencode/client-timeout.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +// Regression tests for issue #2470: sessions stuck on "loading sessions" +// forever after managed OpenCode connection goes half-open. +// +// The SDK client fetch wrapper must bound read requests so a socket that +// neither resolves nor rejects fails after `requestTimeoutMs`, releasing the +// directory bootstrap concurrency slot. Long-lived streams must be excluded: +// POST (prompt/shell/summarize/command) and the `/event` SSE stream. + +type CapturedFetch = (input: string | URL | Request, init?: RequestInit) => Promise; +type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +// `mock(...)` returns a Mock that exposes mockImplementation; keep a typed +// reference so per-test overrides stay type-safe without re-importing. +const runtimeFetchMock = mock(async () => new Response('', { status: 200 })); + +let capturedFetch: CapturedFetch | null = null; + +(mock as unknown as { restore?: () => void }).restore?.(); + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: mock((opts: { fetch: CapturedFetch }) => { + capturedFetch = opts.fetch; + return {}; + }), +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { createRuntimeOpencodeClient } = await import( + `./client?timeout-final=${Date.now()}` +); + +beforeEach(() => { + capturedFetch = null; + runtimeFetchMock.mockImplementation(async () => new Response('', { status: 200 })); +}); + +describe('createRuntimeOpencodeClient fetch wrapper (#2470)', () => { + test('AbortSignal.timeout fires inside a test environment (sanity)', async () => { + const sig = AbortSignal.timeout(20); + const fired = await new Promise((resolve) => { + sig.addEventListener('abort', () => resolve(true)); + setTimeout(() => resolve(false), 200); + }); + expect(fired).toBe(true); + }); + + test('createRuntimeOpencodeClient is exported', () => { + expect(typeof createRuntimeOpencodeClient).toBe('function'); + }); + + test('a GET whose socket never settles rejects with normalized "request timed out" error', async () => { + let firedAt = 0; + let calledAt = Date.now(); + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => { + calledAt = Date.now(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + firedAt = Date.now(); + reject(new DOMException('Aborted', 'AbortError')); + }); + setTimeout( + () => reject(new Error('TIMEOUT_TEST_FAIL: signal never fired')), + 1000, + ); + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const start = Date.now(); + await expect( + capturedFetch!('http://opencode.test/api/session'), + ).rejects.toThrow(/request timed out/); + const elapsed = Date.now() - start; + expect(firedAt).toBeGreaterThanOrEqual(calledAt); + expect(elapsed).toBeLessThan(500); + }); + + test('POST requests (long-running prompt/shell/summarize) are NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('ok', { status: 200 }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/session/prompt', + { method: 'POST' }, + ); + expect(await response.text()).toBe('ok'); + }); + + test('the /event SSE stream is NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('event: ping\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/api/global/event', + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + }); + + test('caller-provided abort signal still wins (no normalized timeout error)', async () => { + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const callerController = new AbortController(); + setTimeout(() => callerController.abort(), 5); + + await expect( + capturedFetch!('http://opencode.test/api/session', { + signal: callerController.signal, + }), + ).rejects.toThrow('Aborted'); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..ae4000fb 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -199,10 +199,61 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: }; }; -const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { +/** + * Upper bound for non-streaming OpenCode read requests. Without it, a socket + * that neither resolves nor rejects (the half-open state described in #2470) + * keeps the bootstrap concurrency slot busy forever and the UI stays on + * "loading sessions". Long-lived streams (POST prompts, the /event SSE) are + * explicitly excluded in {@link createRuntimeOpencodeClient}. + */ +const OPENCODE_REQUEST_TIMEOUT_MS = 30_000; + +const isEventStreamUrl = (input: string | URL | Request): boolean => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + return url.includes('/event'); +}; + +type RuntimeOpencodeClientConfig = { + baseUrl: string; + directory?: string; + /** Read-request timeout in ms. Overridable so tests can use short value. */ + requestTimeoutMs?: number; +}; + +export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => { + const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS; return createOpencodeClient({ ...config, - fetch: runtimeFetch, + fetch: async (input: string | URL | Request, init?: RequestInit) => { + const method = String( + init?.method ?? (input instanceof Request ? input.method : 'GET'), + ).toUpperCase(); + if (isEventStreamUrl(input) || method === 'POST') { + return runtimeFetch(input, init); + } + const timeout = createTimeoutSignal(requestTimeoutMs); + const callerSignal = init?.signal; + const supportsAny = typeof AbortSignal !== 'undefined' + && typeof (AbortSignal as { any?: unknown }).any === 'function'; + const signal: AbortSignal = callerSignal && supportsAny + ? (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal }) + .any([callerSignal, timeout.signal]) + : (callerSignal ?? timeout.signal); + try { + return await runtimeFetch(input, { ...init, signal }); + } catch (error) { + if (timeout.signal.aborted && !callerSignal?.aborted) { + throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`); + } + throw error; + } finally { + timeout.cleanup(); + } + }, }); }; diff --git a/packages/ui/src/sync/retry.test.ts b/packages/ui/src/sync/retry.test.ts new file mode 100644 index 00000000..d1819302 --- /dev/null +++ b/packages/ui/src/sync/retry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { retry } from './retry'; + +describe('retry transient classification (#2470)', () => { + test("'terminated' (undici half-open socket teardown) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new TypeError('terminated'); + }), + ).rejects.toThrow('terminated'); + expect(attempts).toBe(3); + }); + + test("normalized 'request timed out' (SDK read timeout) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new Error('OpenCode request timed out after 30000ms'); + }), + ).rejects.toThrow('request timed out'); + expect(attempts).toBe(3); + }); + + test('caller-initiated abort (AbortError) is NOT retried', async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new DOMException('Aborted', 'AbortError'); + }), + ).rejects.toThrow('Aborted'); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts index 8c7aafea..0b3b0a0d 100644 --- a/packages/ui/src/sync/retry.ts +++ b/packages/ui/src/sync/retry.ts @@ -6,6 +6,10 @@ export interface RetryOptions { retryIf?: (error: unknown) => boolean } +// undici tears down half-open upstream connection with `TypeError: terminated` +// (exact failure from the #2470 logs); the SDK client also rejects reads with +// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS. +// Both are transient — managed process may be restarting. const TRANSIENT_MESSAGES = [ "load failed", "network connection was lost", @@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [ "opencode api unavailable", "503", "502", + "terminated", + "request timed out", ] function isTransientError(error: unknown): boolean { diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 921149c1..f7304d74 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -31,8 +31,17 @@ declare module "bun:test" { export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; export function afterAll(fn: () => void | Promise): void; - export function mock unknown>(fn?: T): T; + // Mock matches the bun:test runtime mock: T (callable) plus spy methods. + // Tests that need to swap implementations at runtime cast through `Mock`. + export interface Mock unknown> { + (...args: Parameters): ReturnType; + mockImplementation(fn: T): Mock; + mockReturnValue(value: ReturnType): Mock; + mockReset(): Mock; + } + export function mock unknown>(fn?: T): Mock; export namespace mock { function module(moduleName: string, factory: () => Record): void; + function restore(): void; } } From ae1c6de470309b75010f2e79b99cfabcdb3a7782 Mon Sep 17 00:00:00 2001 From: gaojunran Date: Sat, 22 Aug 2026 02:33:28 +0800 Subject: [PATCH 002/114] feat(ui): virtualize large file preview in FilesView Files above the editable size cap (MAX_VIEW_CHARS) now render their full content through pierre's Virtualizer (viewport-only DOM) with highlighting on the shared Shiki worker pool instead of a 200k-char main-thread-highlighted slice. Also fixes a pierre virtualization deadlock: forcing the virtualized host to viewport height decoupled the IntersectionObserver judgment box from the scroll content height, so a large scroll jump blanked the file (0 lines). The large-file host now keeps no fixed height; the observer always sees it intersecting. Addresses #2868 (part 1: large-file preview virtualization). --- .../ui/src/components/views/FilesView.tsx | 93 ++++++++++++++----- .../views/useFileViewVirtualizer.ts | 49 ++++++++++ 2 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/views/useFileViewVirtualizer.ts diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 23e73735..b1da1470 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -31,7 +31,9 @@ import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/l import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight'; import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; -import { File as PierreFile } from '@pierre/diffs/react'; +import { File as PierreFile, VirtualizerContext, WorkerPoolContext } from '@pierre/diffs/react'; +import { useWorkerPool } from '@/contexts/DiffWorkerProvider'; +import { useFileViewVirtualizer, type FileViewVirtualizer } from './useFileViewVirtualizer'; import { Dialog, DialogContent, @@ -311,6 +313,23 @@ const isFileMissingError = (error: unknown): boolean => { const MAX_VIEW_CHARS = 200_000; type FileLineEnding = '\n' | '\r\n'; +// Fast cache key for pierre's line/highlight caches: content-derived (not a +// revision counter) so polling reloads and out-of-view changes can never hit +// a stale entry. Mirrors the diff viewer's key scheme. Known residual: two +// files identical in total length and in the first/last 200 characters can +// collide and briefly show stale content; same profile as PierreDiffViewer. +function makeContentCacheKey(contents: string): string { + const sample = contents.length > 400 + ? `${contents.slice(0, 200)}${contents.slice(-200)}` + : contents; + let hash = 0x811c9dc5; + for (let i = 0; i < sample.length; i += 1) { + hash ^= sample.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return `${contents.length}:${hash.toString(16)}`; +} + const detectFileLineEnding = (content: string): FileLineEnding => { let crlf = 0; let lf = 0; @@ -3163,27 +3182,57 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }); }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]); - const renderShikiFileView = React.useCallback((file: FileNode, content: string) => { + const mainViewVirtualizer = useFileViewVirtualizer(); + const fullscreenViewVirtualizer = useFileViewVirtualizer(); + const shikiWorkerPool = useWorkerPool('unified'); + // Files above the editable size cap are rendered as a read-only preview; give + // them the full file content plus pierre's viewport virtualization and the + // shared Shiki worker pool so large files stay responsive. + const isLargeFile = fileContent.length > MAX_VIEW_CHARS; + const largeFileCacheKey = React.useMemo( + () => (isLargeFile ? makeContentCacheKey(fileContent) : undefined), + [fileContent, isLargeFile], + ); + + const renderShikiFileView = React.useCallback((file: FileNode, content: string, virtualizer: FileViewVirtualizer) => { + const fileContents = { + name: file.name, + contents: content, + lang: getLanguageFromExtension(file.path) || undefined, + }; + const pierreFile = (key: string) => ( + + ); + + if (!isLargeFile) { + return
{pierreFile(file.path)}
; + } + + // Large files render through pierre's Virtualizer (viewport-only DOM) and + // the shared Shiki worker pool. The pool is created lazily: until it is + // ready the key carries a 'pending' suffix so the file remounts with the + // worker-backed highlighter instead of silently staying on the main thread. return (
- + + + {pierreFile(`${file.path}:${shikiWorkerPool ? 'pool' : 'pending'}`)} + +
); - }, [currentTheme.metadata.variant, pierreTheme, wrapLines]); + }, [currentTheme.metadata.variant, isLargeFile, largeFileCacheKey, pierreTheme, shikiWorkerPool, wrapLines]); const renderFloatingFileControls = ({ exitFullscreenOnly = false, @@ -3854,7 +3903,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { )} )} - + {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
) : (fileLoading || isPdfAssetAuthLoading) ? ( @@ -3980,7 +4029,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer) ) : (
= ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
- + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
@@ -4322,7 +4371,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer) ) : (
diff --git a/packages/ui/src/components/views/useFileViewVirtualizer.ts b/packages/ui/src/components/views/useFileViewVirtualizer.ts new file mode 100644 index 00000000..a62fed38 --- /dev/null +++ b/packages/ui/src/components/views/useFileViewVirtualizer.ts @@ -0,0 +1,49 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Virtualizer } from '@pierre/diffs'; + +/** + * Owns one pierre `Virtualizer` bound to a scrolling container. + * + * The instance is created on first render (the constructor does no DOM work) + * so a `` mounted inside a `VirtualizerContext.Provider` already + * picks the virtualized path on mount. pierre queues `connect()` calls made + * before `setup()` and flushes them once the real scroller element is bound. + * + * The scroller passed to `setScroller` must be the actual scrolling element: + * pierre reads `scrollTop`/`scrollHeight`/client height and applies its scroll + * fix on that element. + */ +export function useFileViewVirtualizer() { + const [virtualizer] = useState(() => new Virtualizer()); + const setupRef = useRef(false); + + const setScroller = useCallback( + (node: HTMLElement | null) => { + if (node == null) { + // The scroller was removed (e.g. mobile tree/files toggle or exiting + // fullscreen). pierre's setup() no-ops when a root is already bound, + // so tear the binding down or the next mount would silently attach to + // the stale element and the virtualized file would never update. + virtualizer.cleanUp(); + setupRef.current = false; + return; + } + if (setupRef.current) return; + setupRef.current = true; + virtualizer.setup(node, node.firstElementChild ?? undefined); + }, + [virtualizer], + ); + + useLayoutEffect( + () => () => { + setupRef.current = false; + virtualizer.cleanUp(); + }, + [virtualizer], + ); + + return { virtualizer, setScroller }; +} + +export type FileViewVirtualizer = ReturnType; From 236accedc498afc6aa8f279fb8753a16e878ea63 Mon Sep 17 00:00:00 2001 From: CodeNebula <121377123+gaojunran@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:53:19 -0700 Subject: [PATCH 003/114] fix(ui): make timeline dialog fit small screens (#3090) * fix(ui): make timeline dialog fit small screens On phones the timeline dialog squeezed the message list to a couple of rows: header, search box, and the fixed Actions/help block consumed almost all of the 70vh dialog height, and the description text could end up underneath the search box. The dialog no longer scrolls as a whole. The header and search stay fixed (shrink-0), the message list is the only scrollable region and takes the remaining height (min-h-0 flex-1), the dialog is taller on small screens (85dvh), the Actions/help footer collapses to a single action row on phones, and the description is hidden on phones only. Message timestamps no longer wrap either: the time column grows from a fixed 64px minimum and forces nowrap, so "11:14 PM" stays on one line. * fix(ui): keep dialog-level scroll fallback in timeline overflow-y-visible removed the reusable dialog's built-in scrolling fallback: the timeline's header, search, and footer are all shrink-0, so under constrained layouts (very short desktop windows, enlarged text) the fixed controls could leave the popup with no way to reach them once the message list collapsed to zero. Revert to overflow-y-auto. The list is still the only flex-1 min-h-0 child and absorbs space first, so normal mobile and desktop layouts do not scroll; only when fixed content exceeds the dialog height does the popup scroll again, keeping every control reachable. --- .../ui/src/components/chat/TimelineDialog.tsx | 102 ++++++++++-------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 88a03d45..45e0f9f4 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -223,20 +223,48 @@ export const TimelineDialog: React.FC = ({ if (!currentSessionId) return null; + const turnActions = ( + <> + + / + + + ); + return ( - - + + {t('chat.timeline.title')} - - {t('chat.timeline.description')} - + {!isMobile && ( + + {t('chat.timeline.description')} + + )} -
+
= ({
{canLoadEarlier && onLoadEarlier && ( -
+
)} -
+
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')} @@ -312,7 +340,7 @@ export const TimelineDialog: React.FC = ({ onMouseEnter={() => setSelectedIndex(index)} > {messageTime} @@ -373,45 +401,31 @@ export const TimelineDialog: React.FC = ({ )}
-
-

{t('chat.timeline.actions.title')}

-
- - / - + {isMobile ? ( +
+ {turnActions}
-
-
- {t('chat.timeline.help.clickMessage')} + ) : ( +
+

{t('chat.timeline.actions.title')}

+
+ {turnActions}
-
- - {t('chat.timeline.help.undoToPoint')} -
-
- - {t('chat.timeline.help.createSessionFromHere')} +
+
+ {t('chat.timeline.help.clickMessage')} +
+
+ + {t('chat.timeline.help.undoToPoint')} +
+
+ + {t('chat.timeline.help.createSessionFromHere')} +
-
+ )}
); From 9a40eea7c1ad5418af8cf57ae795605485e6b379 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 15:54:18 +0300 Subject: [PATCH 004/114] fix: include archived sessions in header lookup Header now finds the current session in archived sessions too. --- packages/ui/src/components/layout/Header.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index cfc8f555..e6b4ddc0 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -511,7 +511,8 @@ export const Header: React.FC = ({ const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback( (state): HeaderSessionSnapshot | null => { if (!currentSessionId) return null; - const session = state.activeSessions.find((candidate) => candidate.id === currentSessionId); + const session = [...state.activeSessions, ...state.archivedSessions] + .find((candidate) => candidate.id === currentSessionId); if (!session) return null; const record = session as typeof session & { directory?: string | null; slug?: string | null }; return { From 526bbe246dcb6d5ae49183329f9bcc438d119a49 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 16:01:30 +0300 Subject: [PATCH 005/114] fix: delay sidebar item tooltip hover --- packages/ui/src/components/session/sidebar/sortableItems.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx index f5d518c0..4795f92e 100644 --- a/packages/ui/src/components/session/sidebar/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -334,7 +334,7 @@ export const SortableProjectItem: React.FC = ({ ) : ( - +
); }} + maxHeightClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1" tooltipsEnabled={agentMenuOpen} onEscape={() => setAgentMenuOpen(false)} /> @@ -2734,7 +2735,7 @@ export const ModelControls: React.FC = ({
- +
@@ -2750,7 +2751,7 @@ export const ModelControls: React.FC = ({ />
- +
{!agentSearchQuery.trim() && defaultAgentName && ( <> diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index fa70b1a7..495ff6bc 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -128,6 +128,7 @@ function DropdownMenuContent({ }} className={cn( dropdownMenuPopupClass, + "max-h-[calc(100dvh-var(--oc-header-height,56px)-0.5rem)] overflow-y-auto", className )} {...props} diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index cee5c00f..99673fda 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -198,7 +198,7 @@ function SelectContent({ color: 'var(--surface-elevated-foreground)', }} className={cn( - "oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[var(--available-height)] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl", + "oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[min(var(--available-height),calc(100dvh-var(--oc-header-height,56px)-0.5rem))] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl", !alignItemWithTrigger && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", fitContent && "w-max min-w-0", @@ -208,7 +208,7 @@ function SelectContent({ > Date: Sun, 23 Aug 2026 18:27:35 +0300 Subject: [PATCH 008/114] fix(ui): constrain draft menus to chat area --- packages/ui/src/components/chat/ModelControls.tsx | 10 ++++++---- .../chat/composer/ui/DraftTargetSelectors.tsx | 4 ++-- packages/ui/src/components/ui/dropdown-menu.tsx | 15 +++++++++++++-- packages/ui/src/components/ui/select.tsx | 15 ++++++++++++--- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 80d547b2..a290fde8 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -2342,9 +2342,11 @@ export const ModelControls: React.FC = ({
@@ -2401,7 +2403,7 @@ export const ModelControls: React.FC = ({
); }} - maxHeightClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1" + maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1" tooltipsEnabled={agentMenuOpen} onEscape={() => setAgentMenuOpen(false)} /> @@ -2735,7 +2737,7 @@ export const ModelControls: React.FC = ({
- +
@@ -2751,7 +2753,7 @@ export const ModelControls: React.FC = ({ />
- +
{!agentSearchQuery.trim() && defaultAgentName && ( <> diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 8c44d434..c6fba04c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -122,7 +122,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { : } - + {projects.map((project) => ( @@ -144,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 495ff6bc..e8fd327f 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -13,7 +13,9 @@ type AsChildRenderProps = { type DropdownPortalContextValue = { portalContainer: HTMLElement | null; + collisionBoundary: Element | null; setPortalContainer: (container: HTMLElement | null) => void; + setCollisionBoundary: (boundary: Element | null) => void; }; const DropdownPortalContext = React.createContext(null); @@ -36,10 +38,13 @@ function DropdownMenu({ ...props }: React.ComponentProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [collisionBoundary, setCollisionBoundary] = React.useState(null); const portalContextValue = React.useMemo(() => ({ portalContainer, + collisionBoundary, setPortalContainer, - }), [portalContainer]); + setCollisionBoundary, + }), [collisionBoundary, portalContainer]); return ( @@ -62,6 +67,7 @@ function DropdownMenuTrigger({ } const element = target instanceof HTMLElement ? target : null; portalContext.setPortalContainer(resolveDialogContainer(element)); + portalContext.setCollisionBoundary(element?.closest('main') ?? null); }, [portalContext]); const r = renderFromAsChild(asChild, children); @@ -89,6 +95,8 @@ type ContentProps = { alignOffset?: number; portalToBody?: boolean; positionerClassName?: string; + constrainToMain?: boolean; + collisionAvoidance?: React.ComponentProps["collisionAvoidance"]; style?: React.CSSProperties; className?: string; children?: React.ReactNode; @@ -103,6 +111,8 @@ function DropdownMenuContent({ alignOffset, portalToBody = false, positionerClassName, + constrainToMain = false, + collisionAvoidance, style, children, onCloseAutoFocus, @@ -118,6 +128,8 @@ function DropdownMenuContent({ align={align} side={side} alignOffset={alignOffset} + collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined} + collisionAvoidance={collisionAvoidance} className={cn("app-region-no-drag z-50", positionerClassName)} > void; + setCollisionBoundary: (boundary: Element | null) => void; }; const SelectPortalContext = React.createContext(null); @@ -44,10 +46,13 @@ function Select({ ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [collisionBoundary, setCollisionBoundary] = React.useState(null); const portalContextValue = React.useMemo(() => ({ portalContainer, + collisionBoundary, setPortalContainer, - }), [portalContainer]); + setCollisionBoundary, + }), [collisionBoundary, portalContainer]); const handleValueChange = React.useCallback( (value: unknown, eventDetails: SelectRootChangeEventDetails) => { @@ -119,6 +124,7 @@ function SelectTrigger({ } const element = target instanceof HTMLElement ? target : null; portalContext.setPortalContainer(resolveDialogContainer(element)); + portalContext.setCollisionBoundary(element?.closest('main') ?? null); }, [portalContext]); const asChildRender: AsChildRenderProps | null = asChild && React.isValidElement(children) @@ -164,6 +170,7 @@ type SelectContentExtra = { side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; collisionAvoidance?: React.ComponentProps["collisionAvoidance"]; + constrainToMain?: boolean; }; function SelectContent({ @@ -176,6 +183,7 @@ function SelectContent({ side, align, collisionAvoidance, + constrainToMain = false, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); @@ -190,6 +198,7 @@ function SelectContent({ side={side} align={align} collisionAvoidance={collisionAvoidance} + collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined} className="absolute z-[120] pointer-events-auto" > Date: Sun, 23 Aug 2026 19:13:14 +0300 Subject: [PATCH 009/114] fix(chat): unify OpenCode notice styling --- packages/ui/src/components/chat/ChatMessage.tsx | 12 ++---------- .../src/components/chat/message/MessageBody.tsx | 17 +++-------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 3fe0ef62..9b39eb34 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -705,30 +705,25 @@ const ChatMessage: React.FC = ({ } if (errorName === 'SessionRetry') { return { - text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``, - variant: 'info' as const, + text: `Opencode failed to send a message. Retry attempt info: ${detail}`, }; } if (isLikelyProviderAuthFailure(detail)) { return { text: PROVIDER_AUTH_FAILURE_MESSAGE, - variant: 'error' as const, }; } if (detail.trim().toLowerCase() === 'aborted') { return { text: 'The running turn was stopped before OpenCode could send the next message.', - variant: 'info' as const, }; } return { - text: `Opencode failed to send message with error:\n\`${detail}\``, - variant: 'error' as const, + text: `Opencode failed to send message with error: ${detail}`, }; }, [isUser, message.info]); const assistantErrorText = assistantError?.text; - const assistantErrorVariant = assistantError?.variant; const messageTextContent = React.useMemo(() => { if (isUser) { @@ -1089,7 +1084,6 @@ const ChatMessage: React.FC = ({ contextPinPending={pinPending} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'} stickyUserHeaderEnabled={stickyUserHeader} /> @@ -1126,7 +1120,6 @@ const ChatMessage: React.FC = ({ contextPinPending={pinPending} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} userActionsMode="external-actions" stickyUserHeaderEnabled={stickyUserHeader} /> @@ -1169,7 +1162,6 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} turnGroupingContext={turnGroupingContext} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} reviewTransferDirection={reviewTransferDirection} footerProviderID={headerProviderID} footerModelName={headerModelName} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index dc870847..9def5286 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -432,7 +432,6 @@ interface MessageBodyProps { onRevert?: () => void; onFork?: () => void; errorMessage?: string; - errorVariant?: 'error' | 'info'; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; reviewTransferDirection?: ReviewTransferDirection | null; @@ -1094,7 +1093,6 @@ const AssistantMessageBody = React.memo(({ showReasoningTraces = false, turnGroupingContext, errorMessage, - errorVariant = 'error', reviewTransferDirection = null, contextPinned, contextPinPending, @@ -1704,7 +1702,6 @@ const AssistantMessageBody = React.memo(({ const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; const showErrorMessage = Boolean(errorMessage); - const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning'; const isPeekSurface = chatSurfaceMode === 'peek'; const shouldShowMessageActions = hasCopyableText && !isPeekSurface; const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface; @@ -2217,17 +2214,9 @@ const AssistantMessageBody = React.memo(({ {renderedParts} {showErrorMessage && ( -
-
- +
+
+
Date: Sun, 23 Aug 2026 23:40:32 +0300 Subject: [PATCH 010/114] feat(chat): structured context attachments with metadata round-trip Every user-attached context item (diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues/PRs, and new chat-quote comments from the selection menu) is now sent as its own synthetic text part carrying an openchamberContext metadata payload. The model-facing text keeps the previous wording; the timeline reads the metadata back and renders each item as a context card instead of raw prompt text. Legacy messages still render via the old text sniffing. The selection menu gains a Comment option with an inline multiline input, the quoted fragment stays highlighted while commenting, and on mobile the input overlays the composer pill by rendering inside the composer form. Add to chat is renamed Add to input; the menu is restyled and the mobile Copy tile removed. Terminal drafts move their terminal id out of the language field (persisted-draft migration v3), and the dead preview-console source is deleted. --- packages/ui/src/components/chat/ChatInput.tsx | 42 +- .../components/chat/composer/DOCUMENTATION.md | 12 +- .../__tests__/buildOutgoingMessage.test.ts | 83 +-- .../composer/submit/buildOutgoingMessage.ts | 42 +- .../chat/composer/ui/ComposerContextChips.tsx | 17 +- .../chat/composer/ui/MobilePillComposer.tsx | 3 +- .../chat/message/TextSelectionMenu.tsx | 484 ++++++++++++++---- .../chat/message/normalizeUserDisplayParts.ts | 23 + .../src/components/chat/message/partUtils.ts | 8 + .../chat/message/parts/DOCUMENTATION.md | 8 + .../chat/message/parts/UserContextPart.tsx | 132 +++++ .../chat/message/parts/UserTextPart.tsx | 10 + .../ui/src/components/views/TerminalView.tsx | 3 +- packages/ui/src/index.css | 12 + .../ui/src/lib/browser/annotationOverlay.ts | 9 +- packages/ui/src/lib/i18n/messages/de.ts | 17 +- packages/ui/src/lib/i18n/messages/en.ts | 17 +- packages/ui/src/lib/i18n/messages/es.ts | 17 +- packages/ui/src/lib/i18n/messages/fr.ts | 17 +- packages/ui/src/lib/i18n/messages/ja.ts | 17 +- packages/ui/src/lib/i18n/messages/ko.ts | 17 +- packages/ui/src/lib/i18n/messages/pl.ts | 17 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 17 +- packages/ui/src/lib/i18n/messages/uk.ts | 17 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 17 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 17 +- .../ui/src/lib/messages/contextParts.test.ts | 120 +++++ packages/ui/src/lib/messages/contextParts.ts | 280 ++++++++++ .../ui/src/lib/messages/inlineComments.ts | 72 --- .../ui/src/lib/messages/synthetic.test.ts | 22 + packages/ui/src/lib/messages/synthetic.ts | 9 + packages/ui/src/lib/opencode/client.ts | 13 +- ...seInlineCommentDraftStore.terminal.test.ts | 66 ++- .../src/stores/useInlineCommentDraftStore.ts | 66 ++- packages/ui/src/styles/mobile.css | 20 + packages/ui/src/sync/input-store.ts | 2 + packages/ui/src/sync/session-ui-store.ts | 13 +- 37 files changed, 1440 insertions(+), 318 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/UserContextPart.tsx create mode 100644 packages/ui/src/lib/messages/contextParts.test.ts create mode 100644 packages/ui/src/lib/messages/contextParts.ts delete mode 100644 packages/ui/src/lib/messages/inlineComments.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2981248e..1a5a72f4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; -import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -757,21 +756,21 @@ const ChatInputComponent: React.FC = ({ React.useCallback( (state) => { const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; - let previewConsole = 0; let previewAnnotation = 0; let review = 0; let terminal = 0; let prComment = 0; let prCheck = 0; + let chatQuote = 0; for (const draft of drafts) { - if (draft.source === 'preview-console') previewConsole += 1; - else if (draft.source === 'preview-annotation') previewAnnotation += 1; + if (draft.source === 'preview-annotation') previewAnnotation += 1; else if (draft.source === 'terminal') terminal += 1; else if (draft.source === 'pr-comment') prComment += 1; else if (draft.source === 'pr-check') prCheck += 1; + else if (draft.source === 'chat-quote') chatQuote += 1; else review += 1; } - return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`; + return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`; }, [inlineDraftKey] ) @@ -779,11 +778,11 @@ const ChatInputComponent: React.FC = ({ const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; - const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => { + const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { @@ -797,7 +796,7 @@ const ChatInputComponent: React.FC = ({ if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { - if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') { + if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } @@ -930,12 +929,9 @@ const ChatInputComponent: React.FC = ({ const inputSnapshot = getCurrentInputSnapshot(); if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; - const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; - - let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); - if (drafts.length > 0) { - messageToQueue = appendInlineComments(messageToQueue, drafts); - } + // Context drafts stay in their store: the send that later delivers the + // queue consumes them and attaches them as structured context parts. + const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); addToQueue(messageQueueTarget, { @@ -961,7 +957,7 @@ const ChatInputComponent: React.FC = ({ if (!isMobile) { composerRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC = ({ } // Inline review comments and synthetic context are consumed before - // assembly so a failed send can restore exactly what it took. + // assembly so a failed send can restore exactly what it took. Context + // drafts ride with whichever send goes out next, including queued + // auto-sends: queueing leaves them in the store on purpose. const syntheticParts = consumePendingSyntheticParts(); - const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; + const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget ? consumeDrafts(consumedDraftTarget) : []; @@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC = ({ composerAttachments: attachedFiles, inlineComments: drafts, syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], - linkedIssueContext: linkedIssue?.contextText ?? null, + linkedIssue: linkedIssue + ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } + : null, linkedPr: linkedPr - ? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } + ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, }, { parseAgentMention: (text) => { @@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC = ({ }, sanitizeAttachments: sanitizeAttachmentsForSend, collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), - appendComments: (text, comments) => - appendInlineComments(text, comments as InlineCommentDraft[]), buildSkillInstruction: buildSkillMentionInstruction, }); @@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC = ({ reviewCount={reviewCount} prCommentCount={prCommentCount} prCheckCount={prCheckCount} - previewConsoleCount={previewConsoleCount} previewAnnotationCount={previewAnnotationCount} + chatQuoteCount={chatQuoteCount} draftTarget={inlineDraftTarget} onRemoveDraft={removeInlineCommentDraft} onRemoveReviewDrafts={removeReviewDrafts} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index f800230b..f3b03529 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -124,10 +124,14 @@ and the send path reading the same grammar. drawn caret through a class it only writes while applying an update, so the selection has to be the update that follows the focus. - `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text, - inline comments and context into OpenCode's one-primary-plus-parts shape. The - oldest queued message becomes primary; **inline comments attach to the last - body the user authored** rather than becoming their own part; PR instructions - precede the PR diff. + context drafts and linked references into OpenCode's one-primary-plus-parts + shape. The oldest queued message becomes primary. **Every attached context + item (inline comments, terminal selections, browser annotations, PR context, + linked issue/PR) becomes its own synthetic text part carrying structured + metadata** built by `lib/messages/contextParts.ts`; the timeline reads that + metadata back to render context blocks. PR instructions precede the PR diff. + Queueing a message leaves context drafts in their store on purpose — the send + that later delivers the queue consumes them. - `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory, session) identity. Writes are debounced while typing but forced at every edge where the page may stop running, because a pending timer is not a saved diff --git a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts index 751f568b..2c219085 100644 --- a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts +++ b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts'; import { buildOutgoingMessage, type OutgoingMessageDeps, @@ -26,7 +28,6 @@ const deps = (overrides: Partial = {}): OutgoingMessageDeps }, sanitizeAttachments: (files) => [...(files ?? [])], collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]), - appendComments: (text, comments) => `${text}\n[${comments.length} comments]`, buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null), ...overrides, }); @@ -37,7 +38,7 @@ const input = (overrides: Partial = {}): OutgoingMessageIn composerAttachments: [], inlineComments: [], syntheticTexts: [], - linkedIssueContext: null, + linkedIssue: null, linkedPr: null, ...overrides, }); @@ -130,36 +131,51 @@ describe('agent mentions', () => { }); }); -describe('inline comments', () => { - test('attach to the composer text when nothing was queued', () => { +const commentDraft = (overrides: Partial = {}): InlineCommentDraft => ({ + id: 'icd-1', + sessionKey: 's1', + source: 'diff', + fileLabel: 'src/app.ts', + startLine: 3, + endLine: 5, + side: 'modified', + code: 'const x = 1;', + language: 'ts', + text: 'fix this', + createdAt: 1, + ...overrides, +}); + +describe('context drafts', () => { + test('each becomes a synthetic part carrying structured metadata', () => { const result = buildOutgoingMessage(input({ composerText: 'body', - inlineComments: [{}, {}], + inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })], }), deps()); - expect(result.primaryText).toBe('body\n[2 comments]'); + expect(result.primaryText).toBe('body'); + expect(result.additionalParts).toHaveLength(2); + expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft())); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined }))); + expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):'); + expect(result.additionalParts[0].text).toContain('fix this'); }); - test('attach to the last authored part when messages were queued', () => { + test('context parts precede other synthetic context', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'queued' }], - composerText: 'typed', - inlineComments: [{}], + composerText: 'body', + inlineComments: [commentDraft()], + syntheticTexts: ['conflict note'], }), deps()); - expect(result.primaryText).toBe('queued'); - expect(result.additionalParts[0].text).toBe('typed\n[1 comments]'); + expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text)) + .toEqual(['comment', 'conflict note']); }); - test('fall back to primary when the queue produced no additional parts', () => { - const result = buildOutgoingMessage(input({ - queued: [{ content: 'only queued' }], - inlineComments: [{}], - }), deps()); - expect(result.primaryText).toBe('only queued\n[1 comments]'); - }); - - test('no comments changes nothing', () => { - expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText) - .toBe('body'); + test('no drafts changes nothing', () => { + expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts) + .toEqual([]); }); }); @@ -167,26 +183,31 @@ describe('synthetic context', () => { test('a linked PR sends its instructions before its diff', () => { const result = buildOutgoingMessage(input({ composerText: 'review this', - linkedPr: { instructions: 'how to read it', context: 'the diff' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['how to read it', 'the diff']); expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' }); }); test('a linked issue is sent as context', () => { const result = buildOutgoingMessage(input({ composerText: 'fix it', - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); - expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]); + expect(result.additionalParts).toHaveLength(1); + expect(result.additionalParts[0].text).toBe('issue body'); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' }); }); test('synthetic texts precede the linked references', () => { const result = buildOutgoingMessage(input({ composerText: 'x', syntheticTexts: ['conflict note'], - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['conflict note', 'issue body']); @@ -211,7 +232,9 @@ describe('synthetic context', () => { }); test('context alone is still worth sending', () => { - const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps()); + const result = buildOutgoingMessage(input({ + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, + }), deps()); expect(result.isEmpty).toBe(false); }); @@ -230,8 +253,8 @@ describe('full assembly order', () => { queued: [{ content: 'q1' }, { content: 'q2' }], composerText: 'typed /deploy', syntheticTexts: ['synthetic'], - linkedIssueContext: 'issue', - linkedPr: { instructions: 'pr-how', context: 'pr-diff' }, + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, }), deps()); expect(result.primaryText).toBe('q1'); diff --git a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts index e92395aa..15c33d6d 100644 --- a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts +++ b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts @@ -14,12 +14,16 @@ */ import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts'; export interface OutgoingPart { text: string; attachments?: AttachedFile[]; /** Synthetic parts are context for the model, not shown as user content. */ synthetic?: boolean; + /** Structured context (see contextParts.ts), persisted with the part. */ + metadata?: ContextPartMetadata; } export interface OutgoingMessage { @@ -43,12 +47,12 @@ export interface OutgoingMessageInput { /** The composer's own text, or null when this send skips it. */ composerText: string | null; composerAttachments: readonly AttachedFile[]; - /** Inline review comments, appended to the user's last authored text. */ - inlineComments: readonly unknown[]; + /** Context drafts (code comments, terminal selections, annotations, PR context). */ + inlineComments: readonly InlineCommentDraft[]; /** Synthetic context produced elsewhere (conflict resolution, and such). */ syntheticTexts: readonly string[]; - linkedIssueContext: string | null; - linkedPr: { instructions: string; context: string } | null; + linkedIssue: { number: number; title: string; url: string; contextText: string } | null; + linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null; } /** @@ -64,8 +68,6 @@ export interface OutgoingMessageDeps { sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[]; /** Skills named inline with `/name`. */ collectSkillNames: (text: string) => string[]; - /** Append inline review comments to a message body. */ - appendComments: (text: string, comments: readonly unknown[]) => string; /** Instruction telling the model which skills the user named. */ buildSkillInstruction: (names: string[]) => string | null; } @@ -134,33 +136,29 @@ export function buildOutgoingMessage( } } - // Inline comments attach to the last thing the user authored, so they read - // as a continuation of it rather than as a separate turn. - if (input.inlineComments.length > 0) { - const lastAuthored = input.queued.length > 0 && additionalParts.length > 0 - ? additionalParts[additionalParts.length - 1] - : null; - if (lastAuthored) { - lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments); - } else { - primaryText = deps.appendComments(primaryText, input.inlineComments); - } + // Everything below is context for the model, never plain user text. Each + // attached context item becomes its own synthetic part carrying structured + // metadata, so the timeline can render it as a context block after the + // server echoes the message back. + for (const draft of input.inlineComments) { + additionalParts.push(createContextPart(contextPayloadFromDraft(draft))); } - // Everything below is context for the model, never user-visible content. for (const text of input.syntheticTexts) { additionalParts.push({ text, synthetic: true }); } - if (input.linkedIssueContext) { - additionalParts.push({ text: input.linkedIssueContext, synthetic: true }); + if (input.linkedIssue) { + const { number, title, url, contextText } = input.linkedIssue; + additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText)); } if (input.linkedPr) { // Instructions before context: the model is told how to read the diff // before it is given the diff. - additionalParts.push({ text: input.linkedPr.instructions, synthetic: true }); - additionalParts.push({ text: input.linkedPr.context, synthetic: true }); + const { number, title, url, instructions, context } = input.linkedPr; + additionalParts.push({ text: instructions, synthetic: true }); + additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context)); } const skillInstruction = deps.buildSkillInstruction(skillNames); diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index d731e2b0..32373b3f 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -20,12 +20,12 @@ export interface ComposerContextChipsProps { reviewCount: number; prCommentCount: number; prCheckCount: number; - previewConsoleCount: number; previewAnnotationCount: number; + chatQuoteCount: number; draftTarget: InlineCommentDraftTarget | null; onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void; onRemoveReviewDrafts: () => void; - onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void; + onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void; colors: Theme['colors']; } @@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { reviewCount, prCommentCount, prCheckCount, - previewConsoleCount, previewAnnotationCount, + chatQuoteCount, draftTarget, onRemoveDraft, onRemoveReviewDrafts, @@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { /> ) : null} - {previewConsoleCount > 0 ? ( + {chatQuoteCount > 0 ? ( onRemovePreviewDrafts('preview-console')} + label={t('chat.chatInput.chatQuoteContext')} + count={chatQuoteCount} + removeLabel={t('chat.chatInput.chatQuoteContextRemove')} + onRemove={() => onRemovePreviewDrafts('chat-quote')} colors={colors} + icon={} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx index 831efacb..6bb2e10b 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx @@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) { />
( @@ -46,6 +48,103 @@ export const TextSelectionMenu: React.FC = ({ containerR const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); + const [selectedMessageId, setSelectedMessageId] = React.useState(null); + const [commentMode, setCommentMode] = React.useState(false); + const commentModeRef = React.useRef(false); + const [commentText, setCommentText] = React.useState(''); + const commentInputRef = React.useRef(null); + + // While the comment input owns focus the native selection is gone, so the + // quoted fragment is repainted with our own overlay rectangles. Raw + // Range.getClientRects() mixes block-container boxes with text boxes and + // the translucent overlaps paint double-dark bands, so the rects are taken + // from the text nodes only and merged into one strip per visual line. + const [commentRects, setCommentRects] = React.useState(null); + const updateCommentRects = React.useCallback(() => { + const range = pendingSelectionRef.current?.range; + if (!range) { + setCommentRects(null); + return; + } + + const textRects: DOMRect[] = []; + const pushNodeRects = (node: Text) => { + const nodeRange = document.createRange(); + nodeRange.selectNodeContents(node); + if (node === range.startContainer) nodeRange.setStart(node, range.startOffset); + if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset); + // Text rects cover only the glyph box; the native selection paints the + // full line box, so each rect is stretched to its element's line-height. + const lineHeight = node.parentElement + ? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight) + : Number.NaN; + for (const rect of nodeRange.getClientRects()) { + if (rect.width <= 0 || rect.height <= 0) continue; + if (Number.isFinite(lineHeight) && lineHeight > rect.height) { + const expand = (lineHeight - rect.height) / 2; + textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight)); + } else { + textRects.push(rect); + } + } + }; + const root = range.commonAncestorContainer; + if (root instanceof Text) { + pushNodeRects(root); + } else { + // SAFETY: the walker is created with SHOW_TEXT, so every node it + // yields is a Text node. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (range.intersectsNode(node)) pushNodeRects(node as Text); + } + } + + // Merge rects that sit on the same visual line into one strip, the way + // the native selection paints a line box. + const lines: Array<{ left: number; right: number; top: number; bottom: number }> = []; + for (const rect of textRects) { + const line = lines.find((candidate) => ( + Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6 + )); + if (line) { + line.left = Math.min(line.left, rect.left); + line.right = Math.max(line.right, rect.right); + line.top = Math.min(line.top, rect.top); + line.bottom = Math.max(line.bottom, rect.bottom); + } else { + lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }); + } + } + setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top))); + }, []); + + React.useEffect(() => { + if (!commentMode) return; + let frame: number | null = null; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updateCommentRects(); + }); + }; + document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true }); + window.addEventListener('resize', scheduleUpdate); + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + document.removeEventListener('scroll', scheduleUpdate, { capture: true }); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [commentMode, updateCommentRects]); + + // Grow the comment box with its content, up to five lines. + const resizeCommentInput = React.useCallback(() => { + const element = commentInputRef.current; + if (!element) return; + element.style.height = 'auto'; + element.style.height = `${Math.min(element.scrollHeight, 120)}px`; + }, []); const isDraggingRef = React.useRef(false); const [isOpening, setIsOpening] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); @@ -57,6 +156,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const isMenuVisibleRef = React.useRef(false); const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); + const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); const projects = useProjectsStore((state) => state.projects); @@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC = ({ containerR const effectiveDirectory = useEffectiveDirectory(); const sessions = useSessions(); + // Mobile: the comment bar is rendered inside the composer form (its + // positioning context), so it inherits the runtime's own keyboard handling + // — browser viewport resizing and Capacitor choreography alike. This effect + // only centers it on the composer pill in the form's local coordinates; no + // viewport math, which Safari's keyboard handling reliably breaks for + // fixed elements. + React.useEffect(() => { + if (!commentMode || !isMobile) return; + const update = () => { + const element = menuRef.current; + const host = element?.offsetParent; + if (!element || !host) return; + const pill = document.querySelector('[data-mobile-composer-pill="true"]') + ?? document.querySelector('[data-chat-input="true"]'); + const pillRect = pill?.getBoundingClientRect(); + if (!pillRect || pillRect.height <= 0) return; + const hostRect = host.getBoundingClientRect(); + element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`; + element.style.left = `${pillRect.left - hostRect.left}px`; + element.style.width = `${pillRect.width}px`; + element.style.bottom = 'auto'; + }; + update(); + const raf = window.requestAnimationFrame(update); + // The composer relayouts with its own transitions and timeouts that emit + // no event; a light poll keeps the overlay glued to the pill. + const poll = window.setInterval(update, 200); + return () => { + window.cancelAnimationFrame(raf); + window.clearInterval(poll); + }; + }, [commentMode, isMobile]); + React.useEffect(() => { isMenuVisibleRef.current = position.show; }, [position.show]); @@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; + setCommentRects(null); if (!isMenuVisibleRef.current) { return; @@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC = ({ containerR setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); setSelectedTextMarkdown(''); + setSelectedMessageId(null); + setCommentMode(false); + commentModeRef.current = false; + setCommentText(''); isMenuVisibleRef.current = false; }, []); @@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; - const { plainText, markdownText, rect } = pendingSelectionRef.current; + const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; // Position menu above the selection @@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC = ({ containerR setSelectedText(plainText); setSelectedTextMarkdown(markdownText); + setSelectedMessageId(messageId); setPosition({ x: menuX, y: menuY, @@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC = ({ containerR }, [getDesktopClampedX, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { + // While the comment input is open, clicking or typing in it collapses the + // text selection; the captured quote must survive that. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); const container = containerRef.current; @@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC = ({ containerR const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging + const anchorElement = range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; pendingSelectionRef.current = { plainText: text, markdownText: rangeToMarkdown(range, text), rect, + messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null, + range: range.cloneRange(), }; // Only show menu if we're not currently dragging @@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!container) return; // Track when dragging starts - const handleMouseDown = () => { + const handleMouseDown = (event: MouseEvent) => { + // SAFETY: a MouseEvent target inside the document is always a Node; + // `contains` only needs that. + if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) { + return; + } isDraggingRef.current = true; hideMenu(); }; @@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC = ({ containerR // Small delay to ensure selection is finalized mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = null; + // The click that opened the comment input cleared the selection on + // purpose; the input must survive this deferred check. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); if (selection && selection.toString().trim()) { showMenu(); @@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC = ({ containerR if ( menuRef.current && !menuRef.current.contains(e.target as Node) && - !window.getSelection()?.toString().trim() + (commentModeRef.current || !window.getSelection()?.toString().trim()) ) { hideMenu(); } @@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC = ({ containerR }); }, [selectedTextMarkdown, setPendingInputText, hideMenu]); + const handleOpenComment = React.useCallback(() => { + if (!selectedTextMarkdown) return; + setCommentMode(true); + commentModeRef.current = true; + updateCommentRects(); + window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + commentInputRef.current?.focus(); + }); + }, [selectedTextMarkdown, updateCommentRects]); + + const handleAttachComment = React.useCallback(() => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) { + hideMenu(); + return; + } + addContextDraft({ directory: effectiveDirectory, sessionKey }, { + source: 'chat-quote', + fileLabel: selectedMessageId ?? '', + startLine: 1, + endLine: 1, + code: selectedTextMarkdown, + language: '', + text: commentText.trim(), + }); + hideMenu(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); + const handleCreateNewSession = React.useCallback(async () => { if (!selectedText) return; @@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC = ({ containerR window.getSelection()?.removeAllRanges(); }, [selectedText, createSession, setPendingInputText, hideMenu]); - const handleCopy = React.useCallback(async () => { - if (!selectedText) return; - - const result = await copyTextToClipboard(selectedText); - if (!result.ok) { - console.error('Failed to copy:', result.error); - } - - hideMenu(); - window.getSelection()?.removeAllRanges(); - }, [selectedText, hideMenu]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!position.show) return null; + const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0 + ? createPortal( +
+ {commentRects.map((rect, index) => ( +
+ ))} +
, + document.body, + ) + : null; + + const commentInput = ( +
+