From cb90de5b533f9a74957650b3ae9e1fb850c2489c Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 6 Sep 2026 20:38:49 +0900 Subject: [PATCH 1/3] fix: smooth session history scrolling and loading older messages --- .../list/gjc/gjc-sessions.provider.ts | 222 +++++----------- .../providers/tests/gjc-sessions.test.ts | 77 +++++- .../useChatFollowScroll.dom.bun.test.tsx | 17 ++ .../chat/hooks/useChatFollowScroll.ts | 9 +- src/components/chat/hooks/useChatMessages.ts | 3 +- .../useChatSessionState.dom.bun.test.tsx | 235 ++++++++++++++++- .../chat/hooks/useChatSessionState.ts | 162 ++++++------ .../chat/tests/chatMessagesPaneCount.test.tsx | 22 +- .../chat/tests/useChatMessages.test.ts | 51 ++++ .../chat/utils/paneItemIdentity.test.ts | 107 ++++++++ src/components/chat/utils/paneItemIdentity.ts | 82 ++++++ src/components/chat/view/ChatInterface.tsx | 4 +- src/components/chat/view/ChatMessagesPane.tsx | 248 ++++++++++-------- .../view/ChatScrollAnchor.dom.bun.test.tsx | 128 +++++++++ src/components/chat/view/ChatScrollAnchor.tsx | 142 ++++++++++ .../chat/view/GroupedMessageList.tsx | 21 +- .../chat/view/LoadAllMessagesOverlay.tsx | 5 +- src/components/chat/view/MessageComponent.tsx | 2 + .../chat/view/TurnWorkBlock.dom.bun.test.tsx | 187 ++++++++++++- src/i18n/locales/en/chat.json | 2 + src/i18n/locales/ko/chat.json | 2 + src/index.css | 7 +- src/stores/sessionMessageFetch.test.ts | 105 +++++++- src/stores/sessionMessageFetch.ts | 40 +++ src/stores/useSessionStore.dom.bun.test.tsx | 38 ++- src/stores/useSessionStore.ts | 27 +- 26 files changed, 1554 insertions(+), 391 deletions(-) create mode 100644 src/components/chat/utils/paneItemIdentity.test.ts create mode 100644 src/components/chat/utils/paneItemIdentity.ts create mode 100644 src/components/chat/view/ChatScrollAnchor.dom.bun.test.tsx create mode 100644 src/components/chat/view/ChatScrollAnchor.tsx diff --git a/server/modules/providers/list/gjc/gjc-sessions.provider.ts b/server/modules/providers/list/gjc/gjc-sessions.provider.ts index 486e30e9..5d27800c 100644 --- a/server/modules/providers/list/gjc/gjc-sessions.provider.ts +++ b/server/modules/providers/list/gjc/gjc-sessions.provider.ts @@ -5,90 +5,14 @@ import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { assignTranscriptTurns, type TranscriptTurnRecord } from '@/modules/providers/list/gjc/gjc-transcript-turns.js'; import { readGjcTranscriptMessage } from '@/modules/providers/list/gjc/gjc-transcript-message.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; +import { AppError, createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; const PROVIDER = 'gjc'; const MAX_JSONL_LINE_BYTES = 32 * 1024 * 1024; const MAX_BUFFERED_HISTORY_RECORDS = 5_000; const MAX_BUFFERED_HISTORY_BYTES = 64 * 1024 * 1024; -const PAGINATION_RECORD_HEADROOM = 100; - -type BufferedNormalizedMessage = { - message: NormalizedMessage; - byteLength: number; -}; - -/** - * Retains only the newest normalized transcript records. The byte limit accounts - * for the serialized record, which bounds the retained message strings and - * structured tool payloads without retaining an unbounded JSONL transcript. - */ -class NormalizedMessageRingBuffer { - private entries: Array = []; - private startIndex = 0; - private bufferedBytes = 0; - - truncated = false; - - constructor( - private readonly maxRecords: number, - private readonly maxBytes: number, - ) {} - - push(message: NormalizedMessage): void { - const byteLength = Buffer.byteLength(JSON.stringify(message), 'utf8'); - - if (byteLength > this.maxBytes) { - this.truncated = true; - return; - } - - while ( - this.entries.length - this.startIndex >= this.maxRecords - || this.bufferedBytes + byteLength > this.maxBytes - ) { - const oldest = this.entries[this.startIndex]; - if (!oldest) { - break; - } - this.entries[this.startIndex] = undefined; - this.startIndex += 1; - this.bufferedBytes -= oldest.byteLength; - this.truncated = true; - } - - this.entries.push({ message, byteLength }); - this.bufferedBytes += byteLength; - - if (this.startIndex >= 1_024) { - this.entries = this.entries.slice(this.startIndex); - this.startIndex = 0; - } - } - - get messages(): NormalizedMessage[] { - const messages: NormalizedMessage[] = []; - for (let index = this.startIndex; index < this.entries.length; index += 1) { - const entry = this.entries[index]; - if (entry) { - messages.push(entry.message); - } - } - return messages; - } -} - -function getHistoryBufferRecordLimit(limit: number | null, offset: number): number { - if (limit === null) { - return MAX_BUFFERED_HISTORY_RECORDS; - } - - return Math.min( - MAX_BUFFERED_HISTORY_RECORDS, - Math.max(PAGINATION_RECORD_HEADROOM, limit + offset + PAGINATION_RECORD_HEADROOM), - ); -} +type HistoryRow = { ordinal: number; time: number; kind: NormalizedMessage['kind']; toolId?: string }; /** * Streams newline-delimited UTF-8 text while discarding a line as soon as it @@ -197,20 +121,11 @@ async function readTranscriptLineage(sessionFilePath: string): Promise, onMessage: (message: AnyRecord) => void, ): Promise { try { - const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; - - if (!sessionFilePath) { - console.warn(`gjc session file not found for session ${sessionId}`); - return; - } - - // Which turn each record belongs to, and how that turn ended. Both come from - // the transcript, so a reloaded conversation reports what it reported live. - const turns = assignTranscriptTurns(await readTranscriptLineage(sessionFilePath)); for await (const line of readBoundedJsonlLines(sessionFilePath)) { if (!line.trim()) { @@ -354,12 +269,15 @@ async function streamGjcSessionMessages( break; } } - } catch { - // Skip malformed lines. + } catch (error) { + if (error instanceof AppError) throw error; + // Skip malformed lines, not explicit page safety failures. } } } catch (error) { - console.error(`Error reading gjc session messages for ${sessionId}:`, error); + if (error instanceof AppError) throw error; + console.error('Error reading gjc session messages:', error); + throw error; } } @@ -494,72 +412,74 @@ export class GjcSessionsProvider implements IProviderSessions { const { limit = null, offset = 0 } = options; const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); - const messageBuffer = new NormalizedMessageRingBuffer( - getHistoryBufferRecordLimit(normalizedLimit, normalizedOffset), - MAX_BUFFERED_HISTORY_BYTES, - ); - - try { - await streamGjcSessionMessages(sessionId, (rawMessage) => { - for (const message of this.normalizeHistoryEntry(rawMessage, sessionId)) { - messageBuffer.push(message); - } - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[GjcProvider] Failed to load session ${sessionId}:`, message); - return { - messages: [], - total: 0, - hasMore: false, - offset: normalizedOffset, - limit: normalizedLimit, - }; + const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + if (!sessionFilePath) { + return { messages: [], total: 0, hasMore: false, offset: normalizedOffset, limit: normalizedLimit }; } - - const normalized = messageBuffer.messages.sort( - (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(), - ); - - const toolResultMap = new Map(); - for (const msg of normalized) { - if (msg.kind === 'tool_result' && msg.toolId) { - toolResultMap.set(msg.toolId, msg); + const revision = await fsSync.promises.stat(sessionFilePath); + const turns = assignTranscriptTurns(await readTranscriptLineage(sessionFilePath)); + // Index only small descriptors. Tool-result rows must not consume visible + // pagination offsets or evict older messages from a payload ring. + const index: HistoryRow[] = []; + await streamGjcSessionMessages(sessionFilePath, turns, raw => { + for (const message of this.normalizeHistoryEntry(raw, sessionId)) { + const time = Date.parse(message.timestamp); + index.push({ ordinal: index.length, time: Number.isFinite(time) ? time : 0, kind: message.kind, toolId: message.toolId }); } + }); + const chronological = index.sort((a, b) => a.time - b.time || a.ordinal - b.ordinal); + const visible = chronological.filter(row => row.kind !== 'tool_result'); + const end = Math.max(0, visible.length - normalizedOffset); + if (normalizedLimit === null && end > MAX_BUFFERED_HISTORY_RECORDS) { + throw new AppError('History is too large to load at once; use paginated history.', { code: 'HISTORY_PAGE_TOO_LARGE', statusCode: 413 }); + } + const start = Math.max(0, end - Math.min(normalizedLimit ?? MAX_BUFFERED_HISTORY_RECORDS, MAX_BUFFERED_HISTORY_RECORDS)); + const selected = visible.slice(start, end); + const wanted = new Set(selected.map(row => row.ordinal)); + const resultsByTool = new Map(); + const selectedTools = new Set(selected.filter(row => row.kind === 'tool_use' && row.toolId).map(row => row.toolId!)); + for (const row of chronological) { + if (row.kind === 'tool_result' && row.toolId && selectedTools.has(row.toolId)) resultsByTool.set(row.toolId, row.ordinal); } - for (const msg of normalized) { - if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { - const toolResult = toolResultMap.get(msg.toolId); - if (toolResult) { - // The standalone tool_result row is dropped below, so anything the - // UI needs has to be copied here. `toolUseResult` carries the - // runtime's typed details; omitting it silently made a reloaded - // transcript poorer than the turn that produced it. - msg.toolResult = { - content: toolResult.content, - isError: toolResult.isError, - ...(toolResult.toolUseResult === undefined - ? {} - : { toolUseResult: toolResult.toolUseResult }), - }; + for (const ordinal of resultsByTool.values()) wanted.add(ordinal); + + // A second streaming pass retains only the page and its attached results, + // independent of how far back the user has paged. No growing payload cache. + const payloads = new Map(); + let ordinal = 0; + let bytes = 0; + if (wanted.size) await streamGjcSessionMessages(sessionFilePath, turns, raw => { + for (const message of this.normalizeHistoryEntry(raw, sessionId)) { + const position = ordinal++; + if (!wanted.has(position)) continue; + bytes += Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (bytes > MAX_BUFFERED_HISTORY_BYTES) { + throw new AppError('History page exceeds the payload limit; request fewer messages.', { code: 'HISTORY_PAGE_TOO_LARGE', statusCode: 413 }); } + payloads.set(position, message); } + }); + const after = await fsSync.promises.stat(sessionFilePath); + if (after.size !== revision.size || after.mtimeMs !== revision.mtimeMs) { + throw new AppError('Transcript changed while reading history; retry the request.', { code: 'HISTORY_CHANGED', statusCode: 409 }); } - - // Tool results render inside their call, never as standalone timeline rows. - // When the bounded ring has discarded older rows, `total` is a lower bound; - // `hasMore` remains true so callers know the complete history was not retained. - const visibleMessages = normalized.filter((msg) => msg.kind !== 'tool_result'); - const { page, hasMore: pageHasMore } = sliceTailPage( - visibleMessages, - normalizedLimit, - normalizedOffset, - ); - + const messages = selected.map(row => { + const message = payloads.get(row.ordinal)!; + const resultOrdinal = row.toolId ? resultsByTool.get(row.toolId) : undefined; + const result = resultOrdinal === undefined ? undefined : payloads.get(resultOrdinal); + if (message.kind === 'tool_use' && result) { + message.toolResult = { + content: result.content, + isError: result.isError, + ...(result.toolUseResult === undefined ? {} : { toolUseResult: result.toolUseResult }), + }; + } + return message; + }); return { - messages: page, - total: visibleMessages.length, - hasMore: pageHasMore || messageBuffer.truncated, + messages, + total: visible.length, + hasMore: start > 0, offset: normalizedOffset, limit: normalizedLimit, tokenUsage: null, diff --git a/server/modules/providers/tests/gjc-sessions.test.ts b/server/modules/providers/tests/gjc-sessions.test.ts index 82baa86d..e45e3857 100644 --- a/server/modules/providers/tests/gjc-sessions.test.ts +++ b/server/modules/providers/tests/gjc-sessions.test.ts @@ -443,7 +443,7 @@ test('gjc sessions provider returns a folded tool call for the newest one-messag } }); -test('gjc sessions provider keeps only the bounded normalized history tail', { concurrency: false }, async () => { +test('gjc sessions provider bounds page payloads without hiding older history', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'gjc-session-ring-history-')); const workspacePath = path.join(tempRoot, 'workspace'); const sessionsDir = path.join(tempRoot, '.gjc', 'agent', 'sessions', '-workspace'); @@ -475,13 +475,18 @@ test('gjc sessions provider keeps only the bounded normalized history tail', { c await withIsolatedDatabase(async () => { await new GjcSessionSynchronizer().synchronize(); - const history = await new GjcSessionsProvider().fetchHistory('gjc-ring-history'); - - assert.equal(history.total, 5_000); + const provider = new GjcSessionsProvider(); + await assert.rejects(provider.fetchHistory('gjc-ring-history'), { code: 'HISTORY_PAGE_TOO_LARGE' }); + const history = await provider.fetchHistory('gjc-ring-history', { limit: 5_000 }); + assert.equal(history.total, 5_001); assert.equal(history.messages.length, 5_000); assert.equal(history.messages[0]?.content, 'message-1'); assert.equal(history.messages.at(-1)?.content, 'message-5000'); assert.equal(history.hasMore, true); + const oldest = await provider.fetchHistory('gjc-ring-history', { limit: 20, offset: 5_000 }); + assert.equal(oldest.messages[0]?.content, 'message-0'); + assert.equal(oldest.total, 5_001); + assert.equal(oldest.hasMore, false); }); } finally { restoreLiveSessionDir(); @@ -760,6 +765,70 @@ test('history transport truncates oversized gjc tool output and serves the full } }); +test('GJC pages count visible rows, preserve results and reach history beyond the old buffer', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'gjc-visible-pages-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + const restoreLiveSessionDir = patchLiveSessionDir(path.join(tempRoot, 'live-sessions')); + try { + const sessionId = 'visible-pages'; + const filePath = await writeGjcTranscript(tempRoot, sessionId, workspacePath, { firstUserMessage: 'oldest prompt' }); + const records: string[] = []; + for (let i = 0; i < 300; i++) { + const time = new Date(Date.UTC(2026, 6, 9, 0, 0, 10 + i)).toISOString(); + records.push(JSON.stringify({ type: 'message', id: `call-${i}`, parentId: 'msg-1', timestamp: time, + message: { role: 'assistant', content: [{ type: 'toolCall', toolName: 'read', toolCallId: `tool-${i}`, toolInput: { path: `file-${i}` } }] } })); + records.push(JSON.stringify({ type: 'message', id: `result-${i}`, parentId: `call-${i}`, timestamp: time, + message: { role: 'toolResult', toolCallId: `tool-${i}`, content: [{ type: 'text', text: `output-${i}` }], details: { index: i } } })); + } + await appendFile(filePath, `${records.join('\n')}\n`, 'utf8'); + await withIsolatedDatabase(async () => { + await new GjcSessionSynchronizer().synchronize(); + const provider = new GjcSessionsProvider(); + let offset = 0; + let more = true; + const ids = new Set(); + while (more) { + const page = await provider.fetchHistory(sessionId, { limit: 20, offset }); + assert.equal(page.total, 301); + assert.ok(page.messages.length > 0, `page at ${offset} must make progress`); + for (const row of page.messages) { + assert.equal(ids.has(row.id), false); + ids.add(row.id); + if (row.kind === 'tool_use') { + const i = Number(row.toolId!.split('-')[1]); + assert.equal(row.toolResult?.content, `output-${i}`); + assert.deepEqual(row.toolResult?.toolUseResult, { index: i }); + } + } + offset += page.messages.length; + more = page.hasMore; + } + assert.equal(ids.size, 301); + assert.ok(ids.has('msg-1:0:text')); + const exhausted = await provider.fetchHistory(sessionId, { limit: 20, offset: 400 }); + assert.equal(exhausted.hasMore, false); + assert.equal(exhausted.messages.length, 0); + assert.equal(exhausted.total, 301); + + // Deep offset must not be capped by the number of payloads retained. + const later = Array.from({ length: 5100 }, (_, i) => JSON.stringify({ type: 'message', id: `later-${i}`, parentId: 'msg-1', + timestamp: new Date(Date.UTC(2026, 6, 10, 0, 0, i)).toISOString(), message: { role: 'assistant', content: [{ type: 'text', text: `later ${i}` }] } })); + await appendFile(filePath, `${later.join('\n')}\n`, 'utf8'); + const oldest = await provider.fetchHistory(sessionId, { limit: 20, offset: 5400 }); + assert.equal(oldest.total, 5401); + assert.equal(oldest.hasMore, false); + assert.equal(oldest.messages[0]?.content, 'oldest prompt'); + await assert.rejects(provider.fetchHistory(sessionId), { code: 'HISTORY_PAGE_TOO_LARGE' }); + }); + } finally { + restoreLiveSessionDir(); + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('history carries the runtime tool details the transcript persisted', { concurrency: false }, async () => { // The runtime writes its typed per-tool `details` at message level on the // `role: "toolResult"` record. Dropping it here would make a reloaded diff --git a/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx index 765f02c1..2ac3d130 100644 --- a/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx @@ -129,6 +129,23 @@ test('a passive scroll away from the bottom does not stop following', () => { assertAtBottom(container); }); +test('scrollbar interaction stops following growth', () => { + const { container, grow } = setup(); + fireEvent.pointerDown(container); + container.scrollTop = 30; + fireEvent.scroll(container); + grow(); + assert.equal(container.scrollTop, 30); +}); + +test('PageUp from a transcript child stops following', () => { + const { container, content, grow } = setup(); + container.scrollTop = 30; + fireEvent.keyDown(content, { key: 'PageUp' }); + grow(); + assert.equal(container.scrollTop, 30); +}); + after(() => { globalThis.ResizeObserver = NativeResizeObserver; }); diff --git a/src/components/chat/hooks/useChatFollowScroll.ts b/src/components/chat/hooks/useChatFollowScroll.ts index ee33a61b..e398e34b 100644 --- a/src/components/chat/hooks/useChatFollowScroll.ts +++ b/src/components/chat/hooks/useChatFollowScroll.ts @@ -50,20 +50,27 @@ export function useChatFollowScroll({ scrollContainerRef, enabled }: UseChatFoll const touchY = event.touches[0]?.clientY; if (touchStartY !== null && touchY !== undefined && touchY > touchStartY) stopFollowing(); }; + const onPointerDown = (event: PointerEvent) => { + if (event.target === node) stopFollowing(); + }; const onKeyDown = (event: KeyboardEvent) => { - if (document.activeElement === node && ['ArrowUp', 'PageUp', 'Home'].includes(event.key)) stopFollowing(); + const target = event.target; + const editing = target instanceof HTMLElement && (target.matches('input, textarea, select') || target.isContentEditable); + if (!editing && ['ArrowUp', 'PageUp', 'Home'].includes(event.key)) stopFollowing(); }; node.addEventListener('scroll', handleScroll, { passive: true }); node.addEventListener('wheel', onWheel, { passive: true }); node.addEventListener('touchstart', onTouchStart, { passive: true }); node.addEventListener('touchmove', onTouchMove, { passive: true }); + node.addEventListener('pointerdown', onPointerDown, { passive: true }); node.addEventListener('keydown', onKeyDown); return () => { node.removeEventListener('scroll', handleScroll); node.removeEventListener('wheel', onWheel); node.removeEventListener('touchstart', onTouchStart); node.removeEventListener('touchmove', onTouchMove); + node.removeEventListener('pointerdown', onPointerDown); node.removeEventListener('keydown', onKeyDown); }; }, [handleScroll, scrollContainerRef, setFollowing]); diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 8ffec18d..8a302cf3 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -26,6 +26,7 @@ const readTaskNotice = (content: string): TaskNotice | null => { }; const sharedFields = (message: NormalizedMessage) => ({ + id: message.id, sessionId: message.sessionId, displayText: message.displayText, commandName: message.commandName, @@ -113,7 +114,7 @@ function convertRow(message: NormalizedMessage, attachedResult: AttachedResult): isTaskNotification: true, taskStatus: notice.status, ...common, }); if (notice.result) { - output.push({ type: 'assistant', content: cleanAssistantText(notice.result), timestamp: message.timestamp, ...common }); + output.push({ type: 'assistant', content: cleanAssistantText(notice.result), timestamp: message.timestamp, ...common, id: message.id ? `${message.id}:result` : undefined }); } return output; } diff --git a/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx b/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx index 86e1efa5..92769a73 100644 --- a/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, cleanup, renderHook } from '@testing-library/react'; +import { act, cleanup, render, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createElement } from 'react'; @@ -158,3 +158,236 @@ test('subscription cursors survive A → B → A, reconnect, and remount with th expectCursor('a', 'a-next', 1); assert.deepEqual(store.getReplayCursor('b'), { replayGeneration: 'b-first', lastSeq: 3 }); }); + +const message = (id: string, seconds: number, content = id): NormalizedMessage => ({ + id, sessionId: 'session', timestamp: new Date(Date.UTC(2026, 8, 6, 0, 0, seconds)).toISOString(), + provider: 'gjc', kind: 'text', role: 'assistant', content, +}); + +async function setup() { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('{}', { status: 200 })) as typeof fetch; + let rows = [message('first', 20), message('latest', 30)]; + let pageRequests = 0; + let pageResolve: ((value: { addedCount: number; hasMore: boolean; total: number }) => void) | undefined; + let allResolve: ((value: unknown) => void) | undefined; + let state: ReturnType | undefined; + const store = { + setActiveSession() {}, + getMessages: () => rows, + has: () => true, + isStale: () => false, + fetchFromServer: (_id: string, options: { limit: number | null }) => options.limit === null + ? new Promise(resolve => { allResolve = resolve; }) + : Promise.resolve({ serverMessages: rows, hasMore: true, total: 40 }), + fetchMore: () => { pageRequests += 1; return new Promise(resolve => { pageResolve = resolve; }); }, + } as unknown as SessionStore; + const props = { + selectedProject: { projectId: 'project', displayName: 'Project', fullPath: '/project' }, + selectedSession: { id: 'session' }, ws: null, sendMessage() {}, resetStreamingState() {}, + statusCheckSentAtRef: { current: new Map() }, + sessionStore: store, + }; + function Harness() { + state = useChatSessionState(props); + return
; + } + const view = render(); + await act(async () => {}); + act(() => state!.setIsUserScrolledUp(true)); + const update = (next: NormalizedMessage[]) => { + rows = next; + view.rerender(); + }; + return { + state: () => state!, + rows: () => rows, + update, + pageRequests: () => pageRequests, + async page(next: NormalizedMessage[], beforeResolve?: () => void, hasMore = false) { + let request: Promise; + act(() => { request = state!.handleScroll(); }); + assert.ok(pageResolve); + beforeResolve?.(); + await act(async () => { + const addedCount = next.length - rows.length; + rows = next; + pageResolve!({ addedCount, hasMore, total: next.length }); + await request!; + }); + }, + async retry(next: NormalizedMessage[]) { + act(() => state!.retryOlderMessages()); + const addedCount = next.length - rows.length; + rows = next; + await act(async () => { pageResolve!({ addedCount, hasMore: false, total: next.length }); }); + }, + async all(next: NormalizedMessage[]) { + let request: Promise; + act(() => { request = state!.loadAllMessages(); }); + assert.ok(allResolve); + await act(async () => { + rows = next; + allResolve!({ serverMessages: rows, hasMore: false, total: rows.length }); + await request!; + }); + }, + async switchSession() { + props.selectedSession = { id: 'another-session' }; + await act(async () => view.rerender()); + }, + close() { view.unmount(); globalThis.fetch = originalFetch; }, + }; +} + +test('an empty page claiming more history stops automatic retries without hiding history', async () => { + const harness = await setup(); + try { + await harness.page(harness.rows(), undefined, true); + assert.equal(harness.state().historyLoadError, true); + assert.equal(harness.state().hasMoreMessages, true); + assert.equal(harness.state().allMessagesLoaded, false); + for (let index = 0; index < 8; index++) { + await act(async () => { await harness.state().handleScroll(); }); + } + assert.equal(harness.pageRequests(), 1); + assert.equal(harness.state().isLoadingMoreMessages, false); + await harness.retry([message('recovered', 0), ...harness.rows()]); + assert.equal(harness.pageRequests(), 2); + assert.equal(harness.state().historyLoadError, false); + assert.equal(harness.state().allMessagesLoaded, true); + } finally { harness.close(); } +}); + +test('reaching the top loads immediately without another leave-and-return gesture', async () => { + const harness = await setup(); + try { + await harness.page([message('older', 10), ...harness.rows()], () => { + assert.equal(harness.state().isLoadingMoreMessages, true); + assert.equal(harness.state().showLoadAllOverlay, false); + act(() => { void harness.state().handleScroll(); }); + assert.equal(harness.pageRequests(), 1, 'repeated input cannot duplicate an in-flight request'); + }, true); + assert.equal(harness.state().isLoadingMoreMessages, false); + // Stay at scrollTop=0, as when a folded page adds no height or the user + // keeps pulling upward. No detour below the old 100px lock boundary. + await harness.page([message('oldest', 0), ...harness.rows()]); + assert.equal(harness.pageRequests(), 2); + await act(async () => { await harness.state().handleScroll(); }); + assert.equal(harness.pageRequests(), 2, 'end of history never sends another request'); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('reaching the top reveals already cached older rows without clicking a count notice', async () => { + const harness = await setup(); + try { + act(() => harness.update(Array.from({ length: 130 }, (_, index) => message(`row-${index}`, index)))); + assert.equal(harness.state().visibleMessages.length, 100); + await act(async () => { await harness.state().handleScroll(); }); + assert.equal(harness.state().visibleMessages.length, 130); + assert.equal(harness.pageRequests(), 0); + } finally { harness.close(); } +}); + +test('loading an older page does not announce existing messages below as new', async () => { + const harness = await setup(); + try { + await harness.page([message('older', 10), ...harness.rows()]); + assert.equal(harness.state().isUserScrolledUp, true); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('load-all does not announce historical messages as new', async () => { + const harness = await setup(); + try { + await harness.all([message('oldest', 0), message('older', 10), ...harness.rows()]); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('new tail arrival still announces while an older page is in flight', async () => { + const harness = await setup(); + try { + const arrived = message('arrived', 40); + await harness.page([message('older', 10), ...harness.rows(), arrived], () => { + act(() => harness.update([...harness.rows(), arrived])); + assert.equal(harness.state().isLoadingMoreMessages, true); + assert.equal(harness.state().hasNewMessagesBelow, true); + }); + assert.equal(harness.state().hasNewMessagesBelow, true); + } finally { harness.close(); } +}); + +test('a genuine append in the same commit as prepend is announced', async () => { + const harness = await setup(); + try { + await harness.page([message('older', 10), ...harness.rows(), message('arrived', 40)]); + assert.equal(harness.state().hasNewMessagesBelow, true); + act(() => harness.state().scrollToBottomAndReset()); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('tail growth is announced without increasing the message count', async () => { + const harness = await setup(); + try { + act(() => harness.update([harness.rows()[0], message('latest', 30, 'latest streaming addition')])); + assert.equal(harness.state().hasNewMessagesBelow, true); + } finally { harness.close(); } +}); + +test('refreshing identical rows during history loading does not raise a badge', async () => { + const harness = await setup(); + try { + await harness.page([message('older', 10), ...harness.rows().map(row => ({ ...row }))]); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('returning to previously viewed content after rewind is not a new arrival', async () => { + const harness = await setup(); + try { + act(() => harness.state().rewindMessages(1)); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('a finalized stream with only a persisted ID change is not a new message', async () => { + const harness = await setup(); + try { + act(() => harness.state().setIsUserScrolledUp(false)); + act(() => harness.update([harness.rows()[0], { ...message('stream', 30, 'answer'), kind: 'stream_delta' }])); + act(() => harness.state().setIsUserScrolledUp(true)); + act(() => harness.update([harness.rows()[0], message('persisted', 35, 'answer')])); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('switching sessions clears an existing unread badge', async () => { + const harness = await setup(); + try { + act(() => harness.update([...harness.rows(), message('new', 40)])); + assert.equal(harness.state().hasNewMessagesBelow, true); + await harness.switchSession(); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('while following, new content does not create an unread badge', async () => { + const harness = await setup(); + try { + act(() => harness.state().setIsUserScrolledUp(false)); + act(() => harness.update([...harness.rows(), message('new', 40)])); + assert.equal(harness.state().hasNewMessagesBelow, false); + } finally { harness.close(); } +}); + +test('tail-window replacement notices a new message even with an unchanged count', async () => { + const harness = await setup(); + try { + act(() => harness.update([harness.rows()[1], message('new', 40, 'x')])); + assert.equal(harness.state().hasNewMessagesBelow, true); + } finally { harness.close(); } +}); diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index ce4fc980..e709db3a 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { authenticatedFetch } from '../../../utils/api'; @@ -7,6 +7,7 @@ import type { LLMProvider, Project, ProjectSession } from '../../../types/app'; import type { NormalizedMessage, SessionStore } from '../../../stores/useSessionStore'; import type { ChatMessage } from '../types/types'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; +import { getIntrinsicMessageKey } from '../utils/messageKeys'; import { normalizedToChatMessages } from './useChatMessages'; import { useChatFollowScroll } from './useChatFollowScroll'; @@ -29,7 +30,6 @@ type Props = { showImagePreviews?: boolean; }; -type ScrollSnapshot = { height: number; top: number }; type SearchRequest = { timestamp?: string; uuid?: string; snippet?: string }; export function shouldRefreshCachedImageWindow( @@ -70,6 +70,28 @@ function normalizeLocalMessage(message: ChatMessage, sessionId: string): Normali }; } +function hasNewTailContent(previous: ChatMessage[], next: ChatMessage[]): boolean { + const before = previous.at(-1); + const after = next.at(-1); + if (!before || !after) return false; + const beforeKey = getIntrinsicMessageKey(before); + const afterKey = getIntrinsicMessageKey(after); + if (before === after || (beforeKey !== null && beforeKey === afterKey)) { + return (after.content?.length ?? 0) > (before.content?.length ?? 0); + } + // Rewind/removal exposes a message we already had, not a new arrival. + if (afterKey !== null && previous.some(message => getIntrinsicMessageKey(message) === afterKey)) return false; + // Prepending history leaves the old tail last; appending puts a row after it. + if (beforeKey !== null && next.some(message => getIntrinsicMessageKey(message) === beforeKey)) return true; + // A refreshed window can replace IDs (notably a stream's persisted final row). + // Equal final text is reconciliation, while a genuinely newer tail is an arrival. + if (before.isStreaming && after.type === before.type && after.content === before.content) return false; + return new Date(after.timestamp).getTime() > new Date(before.timestamp).getTime() + || (Boolean(before.isStreaming) && after.type === before.type + && Boolean(after.content?.startsWith(before.content ?? '')) + && (after.content?.length ?? 0) > (before.content?.length ?? 0)); +} + function clearTimer(timer: MutableRefObject | null>) { if (timer.current) clearTimeout(timer.current); timer.current = null; @@ -91,6 +113,7 @@ export function useChatSessionState({ const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); + const [historyLoadError, setHistoryLoadError] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); const [hasNewMessagesBelow, setHasNewMessagesBelow] = useState(false); @@ -119,38 +142,34 @@ export function useChatSessionState({ const storeSessionRef = useRef(null); const pendingEchoRef = useRef(null); const messageLengthRef = useRef(0); - const priorContentRef = useRef({ count: 0, size: 0 }); + const priorContentRef = useRef<{ sessionId: string | null; messages: ChatMessage[] }>({ sessionId: null, messages: [] }); const previousTriggerRef = useRef(newSessionTrigger ?? 0); const imageStateRef = useRef({ sessionKey: null as string | null, enabled: showImagePreviews }); const loadedKeyRef = useRef(null); const initialScrollRef = useRef(true); const searchInProgressRef = useRef(false); const loadingMoreRef = useRef(false); + const historyLoadErrorRef = useRef(false); const loadedAllRef = useRef(false); - const topRequestLockRef = useRef(false); - const nearTopRef = useRef(false); - const restoreScrollRef = useRef(null); const ignoredOffsetRef = useRef(0); - const overlayTimerRef = useRef | null>(null); const finishedTimerRef = useRef | null>(null); const createDiff = useMemo(createCachedDiffCalculator, []); const resetPagination = useCallback((resetVisibility: boolean) => { ignoredOffsetRef.current = 0; - loadedAllRef.current = false; loadingMoreRef.current = false; - topRequestLockRef.current = false; - nearTopRef.current = false; - restoreScrollRef.current = null; + historyLoadErrorRef.current = false; + setHistoryLoadError(false); + setIsLoadingMoreMessages(false); + loadedAllRef.current = false; + setHasMoreMessages(false); setTotalMessages(0); setAllMessagesLoaded(false); - setIsLoadingMoreMessages(false); setIsLoadingAllMessages(false); setLoadAllJustFinished(false); setShowLoadAllOverlay(false); if (resetVisibility) setVisibleMessageCount(FIRST_VISIBLE_COUNT); - clearTimer(overlayTimerRef); clearTimer(finishedTimerRef); }, []); @@ -217,7 +236,7 @@ export function useChatSessionState({ if (activeSessionRef.current) sessionStore.clearRealtime(activeSessionRef.current); }, [sessionStore]); const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []); - const followScrollEnabled = !isLoadingMoreMessages && !restoreScrollRef.current && !searchInProgressRef.current; + const followScrollEnabled = !isLoadingMoreMessages && !isLoadingAllMessages && !searchInProgressRef.current; const { isFollowing, setFollowing, @@ -244,96 +263,75 @@ export function useChatSessionState({ } }, [allMessagesLoaded, scrollToBottom]); - const loadOlderMessages = useCallback(async (node: HTMLDivElement) => { - if (loadingMoreRef.current || isLoadingMoreMessages || loadedAllRef.current || !hasMoreMessages || !selectedSession || !selectedProject) return false; + const loadOlderMessages = useCallback(async () => { + if (loadingMoreRef.current || historyLoadErrorRef.current || isLoadingMoreMessages || loadedAllRef.current || !hasMoreMessages || !selectedSession || !selectedProject) return false; + const requestId = selectedSession.id; loadingMoreRef.current = true; setIsLoadingMoreMessages(true); const requestView = requestViewRef.current; - const old = { height: node.scrollHeight, top: node.scrollTop }; + setFollowing(false); try { - const page = await sessionStore.fetchMore(selectedSession.id, { limit: PAGE_SIZE, includeImages: showImagePreviews }); + const page = await sessionStore.fetchMore(requestId, { limit: PAGE_SIZE, includeImages: showImagePreviews }); if (requestViewRef.current !== requestView) return false; - if (!page) return false; - if (!page.serverMessages.length) { - if (!page.hasMore) { - loadedAllRef.current = true; - setAllMessagesLoaded(true); - setHasMoreMessages(false); - clearTimer(overlayTimerRef); - setShowLoadAllOverlay(false); - } + if (!page || (page.addedCount === 0 && page.hasMore)) { + // Never spin at one offset or pretend unreachable history is complete. + historyLoadErrorRef.current = true; + setHistoryLoadError(true); return false; } - restoreScrollRef.current = old; setHasMoreMessages(page.hasMore); setTotalMessages(page.total); - setVisibleMessageCount(value => value + PAGE_SIZE); + if (page.addedCount > 0) setVisibleMessageCount(value => value + page.addedCount); if (!page.hasMore) { loadedAllRef.current = true; setAllMessagesLoaded(true); - clearTimer(overlayTimerRef); setShowLoadAllOverlay(false); } - return true; + return page.addedCount > 0; } finally { if (requestViewRef.current === requestView) { loadingMoreRef.current = false; setIsLoadingMoreMessages(false); } } - }, [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore, showImagePreviews]); + }, [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore, setFollowing, showImagePreviews]); + + const retryOlderMessages = useCallback(() => { + historyLoadErrorRef.current = false; + setHistoryLoadError(false); + void loadOlderMessages(); + }, [loadOlderMessages]); const handleScroll = useCallback(async () => { const node = scrollContainerRef.current; if (!node) return; const bottom = isNearBottom(); + if (!bottom) initialScrollRef.current = false; if (bottom) setHasNewMessagesBelow(false); - const atTop = node.scrollTop < 100; - if (atTop && hasMoreMessages && !loadedAllRef.current) { - if (!nearTopRef.current) { - nearTopRef.current = true; - clearTimer(overlayTimerRef); - setShowLoadAllOverlay(true); - overlayTimerRef.current = setTimeout(() => { - setShowLoadAllOverlay(false); - overlayTimerRef.current = null; - }, 2500); - } - } else if (!atTop) { - nearTopRef.current = false; - } - if (loadedAllRef.current) return; - if (!atTop) { - topRequestLockRef.current = false; + // The in-flight request guard is enough. Requiring a trip away from the + // top after every page forces a second gesture before history can continue. + if (node.scrollTop >= 100) return; + if (chatMessages.length > visibleMessageCount) { + setFollowing(false); + setVisibleMessageCount(count => count + 100); return; } - if (topRequestLockRef.current) { - if (node.scrollTop > 20) topRequestLockRef.current = false; - return; - } - if (await loadOlderMessages(node)) topRequestLockRef.current = true; - }, [hasMoreMessages, isNearBottom, loadOlderMessages]); - - useLayoutEffect(() => { - const saved = restoreScrollRef.current; - const node = scrollContainerRef.current; - if (!saved || !node) return; - node.scrollTop = saved.top + Math.max(node.scrollHeight - saved.height, 0); - restoreScrollRef.current = null; - }, [chatMessages.length]); + await loadOlderMessages(); + }, [chatMessages.length, isNearBottom, loadOlderMessages, setFollowing, visibleMessageCount]); useEffect(() => { if (!searchInProgressRef.current) { initialScrollRef.current = true; setVisibleMessageCount(FIRST_VISIBLE_COUNT); } - topRequestLockRef.current = false; - restoreScrollRef.current = null; - nearTopRef.current = false; setIsUserScrolledUp(false); }, [selectedProject?.projectId, selectedSession?.id, setIsUserScrolledUp]); useEffect(() => { + if (!isFollowing) { + initialScrollRef.current = false; + return; + } if (!initialScrollRef.current || isLoadingSessionMessages || !scrollContainerRef.current) return; if (!chatMessages.length || searchInProgressRef.current) { initialScrollRef.current = false; @@ -358,7 +356,7 @@ export function useChatSessionState({ }; frameId = requestAnimationFrame(settle); return () => cancelAnimationFrame(frameId); - }, [chatMessages.length, isLoadingSessionMessages]); + }, [chatMessages.length, isFollowing, isLoadingSessionMessages]); useEffect(() => { if (!selectedProject || !selectedSession) { @@ -525,19 +523,18 @@ export function useChatSessionState({ const visibleMessages = useMemo(() => chatMessages.length <= visibleMessageCount ? chatMessages : chatMessages.slice(-visibleMessageCount), [chatMessages, visibleMessageCount]); - const lastMessage = chatMessages[chatMessages.length - 1]; - const finalMessageSize = typeof lastMessage?.content === 'string' ? lastMessage.content.length : 0; useEffect(() => { const before = priorContentRef.current; - const advanced = chatMessages.length > before.count || finalMessageSize > before.size; - priorContentRef.current = { count: chatMessages.length, size: finalMessageSize }; - if (!chatMessages.length || !advanced || loadingMoreRef.current || isLoadingMoreMessages || restoreScrollRef.current || searchInProgressRef.current) return; - if (!isFollowing) setHasNewMessagesBelow(true); - }, [chatMessages.length, finalMessageSize, isFollowing, isLoadingMoreMessages]); - useEffect(() => { - setHasNewMessagesBelow(false); - priorContentRef.current = { count: 0, size: 0 }; - }, [activeSession]); + priorContentRef.current = { sessionId: activeSession, messages: chatMessages }; + if (before.sessionId !== activeSession) { + setHasNewMessagesBelow(false); + return; + } + if (isLoadingSessionMessages || searchInProgressRef.current || isFollowing) return; + // Loading flags cannot identify insertion direction: pagination and realtime + // updates may commit together, or a genuine arrival may happen mid-request. + if (hasNewTailContent(before.messages, chatMessages)) setHasNewMessagesBelow(true); + }, [activeSession, chatMessages, isFollowing, isLoadingSessionMessages]); useEffect(() => { const node = scrollContainerRef.current; if (!node) return; @@ -553,9 +550,7 @@ export function useChatSessionState({ loadingMoreRef.current = true; setIsLoadingAllMessages(true); setShowLoadAllOverlay(true); - clearTimer(overlayTimerRef); - const node = scrollContainerRef.current; - const previous = node ? { height: node.scrollHeight, top: node.scrollTop } : null; + setFollowing(false); try { const window = await sessionStore.fetchFromServer(requestId, { limit: null, offset: 0, includeImages: showImagePreviews }); if (requestViewRef.current !== requestView) return; @@ -564,7 +559,6 @@ export function useChatSessionState({ setShowLoadAllOverlay(false); return; } - if (previous) restoreScrollRef.current = previous; setHasMoreMessages(false); setTotalMessages(window.total); ignoredOffsetRef.current = window.total; @@ -588,7 +582,7 @@ export function useChatSessionState({ setIsLoadingAllMessages(false); } } - }, [isLoadingAllMessages, selectedProject, selectedSession, sessionStore, showImagePreviews]); + }, [isLoadingAllMessages, selectedProject, selectedSession, sessionStore, setFollowing, showImagePreviews]); const loadEarlierMessages = useCallback(() => setVisibleMessageCount(count => count + 100), []); return { @@ -603,6 +597,8 @@ export function useChatSessionState({ setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, + historyLoadError, + retryOlderMessages, hasMoreMessages, totalMessages, isUserScrolledUp, diff --git a/src/components/chat/tests/chatMessagesPaneCount.test.tsx b/src/components/chat/tests/chatMessagesPaneCount.test.tsx index 652f82aa..8b548d2e 100644 --- a/src/components/chat/tests/chatMessagesPaneCount.test.tsx +++ b/src/components/chat/tests/chatMessagesPaneCount.test.tsx @@ -18,7 +18,7 @@ function renderCount(loaded: number, persistedTotal: number, extra: Partial [], selectedProject: { projectId: 'project', fullPath: '/project', displayName: 'Project' }, @@ -26,29 +26,23 @@ function renderCount(loaded: number, persistedTotal: number, extra: Partial { +test('realtime rows beyond the persisted total do not show stale totals', () => { const html = renderCount(81, 64); - assert.match(html, /Loaded messages: 81/); - assert.doesNotMatch(html, /Displaying 81 of|\(64\)|\(81\)/); - assert.match(html, /Scroll upward for more/); + assert.doesNotMatch(html, /Displaying 81 of|Loaded messages:|\(64\)|\(81\)|Scroll upward for more/); assert.match(html, /]*>[\s\S]*?Get all messages[\s\S]*?<\/button>/); }); -test('a usable persisted total keeps the existing counter and load-all total', () => { +test('a usable persisted total remains on the explicit load-all control only', () => { for (const total of [81, 100]) { const html = renderCount(81, total); - assert.match(html, new RegExp(`Displaying 81 of ${total} messages`)); + assert.doesNotMatch(html, /Displaying|Scroll upward for more|Loaded messages:/); assert.match(html, new RegExp(`\\(${total}\\)`)); - assert.match(html, /Scroll upward for more/); - assert.doesNotMatch(html, /Loaded messages:/); } }); -test('an unknown persisted total still shows the loaded count and pagination hint', () => { +test('an unknown persisted total does not add an idle pagination notice', () => { const html = renderCount(3, 0); - assert.match(html, /Loaded messages: 3/); - assert.match(html, /Scroll upward for more/); - assert.doesNotMatch(html, / of 0 messages|\(0\)/); + assert.doesNotMatch(html, /Loaded messages:|Scroll upward for more| of 0 messages|\(0\)/); }); test('loading and finished pagination retain their existing counter visibility', () => { @@ -61,7 +55,7 @@ test('loading and finished pagination retain their existing counter visibility', test('locally hidden rows retain the earlier and all-message controls without server pagination', () => { const html = renderCount(81, 64, { hasMoreMessages: false }); - assert.match(html, /Displaying the latest 20 messages of 81/); + assert.doesNotMatch(html, /Displaying the latest/); assert.match(html, /Get earlier messages/); assert.match(html, /Get all messages/); }); diff --git a/src/components/chat/tests/useChatMessages.test.ts b/src/components/chat/tests/useChatMessages.test.ts index dafbddba..4bab73e0 100644 --- a/src/components/chat/tests/useChatMessages.test.ts +++ b/src/components/chat/tests/useChatMessages.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import type { NormalizedMessage } from '../../../stores/useSessionStore'; import { normalizedToChatMessages } from '../hooks/useChatMessages'; import { isToolCallRunning } from '../utils/toolActivity'; +import { assignMessageKeys } from '../utils/messageKeys'; /* * The conversion is what the transcript renders from, and the pane's rows @@ -39,6 +40,8 @@ test('a call converts again when its result lands, whether inline or as its own const call = row({ id: 'c', kind: 'tool_use', toolId: 't1', toolName: 'read', toolInput: { path: 'a.ts' } }); const pending = normalizedToChatMessages([call])[0]; assert.equal(pending.toolResult, null); + assert.equal(pending.id, 'c'); + assert.equal(pending.toolId, 't1'); const result = row({ id: 'r', kind: 'tool_result', toolId: 't1', content: 'contents', isError: false }); const [paired, ...rest] = normalizedToChatMessages([call, result]); @@ -60,6 +63,37 @@ test('a call converts again when its result lands, whether inline or as its own const [own] = normalizedToChatMessages([inline]); assert.notEqual(own, pending); assert.equal(own.toolResult?.content, 'inline'); + for (const message of [paired, superseded, own]) { + assert.equal(message.id, 'c', 'the tool call retains its persisted row ID when its result changes'); + assert.equal(message.toolId, 't1', 'row identity does not replace the tool pairing ID'); + } +}); + +test('prepending text with the same timestamp and content prefix keeps distinct row IDs and keys', () => { + const prefix = 'The same leading text that exceeds the forty-eight character key preview: '; + const existing = row({ id: 'existing', role: 'assistant', content: `${prefix}existing` }); + const first = normalizedToChatMessages([existing]); + const firstKeys = assignMessageKeys(first); + const older = row({ id: 'older', role: 'assistant', content: `${prefix}older` }); + const prepended = normalizedToChatMessages([older, existing]); + const prependedKeys = assignMessageKeys(prepended); + + assert.equal(older.timestamp, existing.timestamp); + assert.deepEqual(prepended.map((message) => message.id), ['older', 'existing']); + assert.equal(prepended[1], first[0]); + assert.equal(prependedKeys(prepended[1]), firstKeys(first[0])); + assert.notEqual(prependedKeys(prepended[0]), prependedKeys(prepended[1])); +}); + +test('changing a text row replaces its output without changing its ID or key', () => { + const original = row({ id: 'answer', role: 'assistant', content: 'Before' }); + const first = normalizedToChatMessages([original]); + const updated = normalizedToChatMessages([{ ...original, content: 'After' }]); + + assert.notEqual(updated[0], first[0]); + assert.equal(updated[0].content, 'After'); + assert.equal(updated[0].id, original.id); + assert.equal(assignMessageKeys(updated)(updated[0]), assignMessageKeys(first)(first[0])); }); test('a text row that yields two messages yields the same two next time', () => { @@ -69,9 +103,26 @@ test('a text row that yields two messages yields the same two next time', () => }); const first = normalizedToChatMessages([notice]); assert.deepEqual(first.map((message) => message.content), ['Done', 'All green']); + assert.deepEqual(first.map((message) => message.id), ['n', 'n:result']); const second = normalizedToChatMessages([notice]); assert.equal(second[0], first[0]); assert.equal(second[1], first[1]); + + const updated = normalizedToChatMessages([{ + ...notice, + content: 'failedFailedOne failure', + }]); + assert.deepEqual(updated.map((message) => message.content), ['Failed', 'One failure']); + assert.deepEqual(updated.map((message) => message.id), first.map((message) => message.id)); + assert.notEqual(updated[0], first[0]); + assert.notEqual(updated[1], first[1]); + + const summaryOnly = normalizedToChatMessages([{ + ...notice, + content: 'runningWorking', + }]); + assert.equal(summaryOnly.length, 1); + assert.equal(summaryOnly[0].id, first[0].id, 'the summary keeps its identity when a result appears or disappears'); }); test('partial tool results preserve their running state through chat conversion', () => { diff --git a/src/components/chat/utils/paneItemIdentity.test.ts b/src/components/chat/utils/paneItemIdentity.test.ts new file mode 100644 index 00000000..36f53172 --- /dev/null +++ b/src/components/chat/utils/paneItemIdentity.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { ChatMessage } from '../types/types'; + +import { assignMessageKeys, getIntrinsicMessageKey } from './messageKeys'; +import { reconcilePaneItemIdentities } from './paneItemIdentity'; +import type { PaneItemIdentity } from './paneItemIdentity'; +import { isToolGroupItem } from './toolGrouping'; +import type { ToolOutputDensity } from './toolOutputDensity'; +import { isTurnWorkBlockItem } from './turnWork'; +import type { PaneListItem } from './turnWork'; + +const message = (id: string): ChatMessage => ({ + id, + type: 'assistant', + content: '', + timestamp: '2026-09-06T00:00:00.000Z', + isToolUse: true, + toolName: 'read', + toolId: 'provider-reuses-this-call-id', +}); + +const group = (kind: 'work' | 'tool', ids: string[]): PaneListItem => { + const messages = ids.map(message); + return kind === 'work' + ? { _isWorkBlock: true, messages, timestamp: '', startedAt: null, endedAt: null, isTail: true } + : { _isGroup: true, toolName: 'read', messages, timestamp: '' }; +}; + +const reconcile = ( + items: PaneListItem[], + previous: readonly PaneItemIdentity[] = [], + density: ToolOutputDensity = 'balanced', +) => reconcilePaneItemIdentities( + items, + density, + assignMessageKeys(items.flatMap((item) => isTurnWorkBlockItem(item) || isToolGroupItem(item) ? item.messages : [item])), + previous, +); + +for (const kind of ['work', 'tool'] as const) { + test(`${kind} identity survives repeated prepend, append and cloned constituent messages`, () => { + let identities = reconcile([group(kind, ['b', 'c'])]); + const originalKey = identities[0].key; + for (const ids of [['a', 'b', 'c'], ['a', 'b', 'c', 'd'], ['older', 'a', 'b', 'c', 'd', 'e']]) { + identities = reconcile([group(kind, ids)], identities); + assert.equal(identities[0].key, originalKey); + assert.deepEqual(identities[0].messageKeys, ids.map((id) => getIntrinsicMessageKey(message(id)))); + } + }); + + test(`${kind} disjoint groups get unique identities and do not claim a retained group key`, () => { + const previous = reconcile([group(kind, ['b', 'c'])]); + const current = reconcile([ + group(kind, ['earlier']), + group(kind, ['a', 'b', 'c', 'd']), + group(kind, ['later']), + ], previous); + assert.equal(current[1].key, previous[0].key); + assert.equal(new Set(current.map(({ key }) => key)).size, 3); + const unrelated = reconcile([group(kind, ['replacement'])], current); + assert.ok(current.every(({ key }) => key !== unrelated[0].key)); + }); + + test(`${kind} split and merge never reuse one previous key for two current groups`, () => { + const original = reconcile([group(kind, ['b', 'c'])]); + const extended = reconcile([group(kind, ['a', 'b', 'c'])], original); + // The first split retains a key originally seeded by b; the second starts + // with b and must allocate a distinct key rather than stealing that key. + const split = reconcile([group(kind, ['a']), group(kind, ['b', 'c'])], extended); + assert.equal(split[0].key, original[0].key); + assert.notEqual(split[0].key, split[1].key); + const merged = reconcile([group(kind, ['a', 'b', 'c'])], split); + assert.equal(merged[0].key, split[1].key, 'the largest constituent overlap wins a merge'); + const splitAgain = reconcile([group(kind, ['a', 'b']), group(kind, ['c'])], merged); + assert.equal(new Set(splitAgain.map(({ key }) => key)).size, 2); + }); + + test(`${kind} density changes reset group identity without mutating the previous render`, () => { + const items = [group(kind, ['a', 'b'])]; + const previous = reconcile(items); + const snapshot = structuredClone(previous); + const compact = reconcile(items, previous, 'compact'); + assert.notEqual(compact[0].key, previous[0].key); + assert.deepEqual(previous, snapshot); + }); +} + +test('ordinary pane messages retain their assigned IDs across prepend, content updates and density changes', () => { + const existing: ChatMessage = { ...message('answer'), isToolUse: false, content: 'Initial answer' }; + const previous = reconcile([existing]); + const changed = { ...existing, content: 'A completely revised answer', timestamp: '2026-09-07T00:00:00.000Z' }; + const earlier: ChatMessage = { ...message('earlier'), isToolUse: false }; + const current = reconcile([earlier, changed], previous, 'compact'); + assert.equal(current[1].key, getIntrinsicMessageKey(existing)); + assert.equal(current[1].key, previous[0].key); + assert.notEqual(current[0].key, current[1].key); +}); + +test('an empty pending work block keeps a unique key only within the same density', () => { + const pending = reconcile([group('work', [])]); + const rerendered = reconcile([group('work', [])], pending); + assert.equal(rerendered[0].key, pending[0].key); + const compact = reconcile([group('work', [])], rerendered, 'compact'); + assert.notEqual(compact[0].key, pending[0].key); +}); diff --git a/src/components/chat/utils/paneItemIdentity.ts b/src/components/chat/utils/paneItemIdentity.ts new file mode 100644 index 00000000..e9399e93 --- /dev/null +++ b/src/components/chat/utils/paneItemIdentity.ts @@ -0,0 +1,82 @@ +import type { ChatMessage } from '../types/types'; + +import { isToolGroupItem } from './toolGrouping'; +import type { ToolOutputDensity } from './toolOutputDensity'; +import { isTurnWorkBlockItem } from './turnWork'; +import type { PaneListItem } from './turnWork'; + +export interface PaneItemIdentity { + key: string; + groupKind: string | null; + messageKeys: readonly string[]; +} + +/** + * Reconcile groups by their constituent message IDs, not either endpoint: + * prepending changes the first call and streaming changes the last. A previous + * key belongs to at most one current group, including when a group splits. + * Callers retain only the previous render's identities in React state, and + * clear them on session changes. Density is part of the group kind so changing + * density deliberately resets folds. Ordinary messages keep their own keys. + */ +export function reconcilePaneItemIdentities( + items: readonly PaneListItem[], + density: ToolOutputDensity, + getMessageKey: (message: ChatMessage) => string, + previous: readonly PaneItemIdentity[] = [], +): PaneItemIdentity[] { + const previousByMessage = new Map(); + for (const identity of previous) { + if (identity.groupKind === null) continue; + for (const messageKey of identity.messageKeys) previousByMessage.set(messageKey, identity); + } + + // Reserve previous keys before allocating new ones: a disjoint group added + // above an existing group must not steal the latter's retained key. + const reservedKeys = new Set(previous.map((identity) => identity.key)); + const claimed = new Set(); + for (const item of items) { + if (!isTurnWorkBlockItem(item) && !isToolGroupItem(item)) reservedKeys.add(getMessageKey(item)); + } + + return items.map((item) => { + const groupKind = isTurnWorkBlockItem(item) + ? `work-${density}` + : isToolGroupItem(item) ? `tool-group-${density}-${item.toolName}` : null; + if (!isTurnWorkBlockItem(item) && !isToolGroupItem(item)) { + return { key: getMessageKey(item), groupKind: null, messageKeys: [] }; + } + + const messageKeys = item.messages.map(getMessageKey); + const overlaps = new Map(); + for (const messageKey of messageKeys) { + const candidate = previousByMessage.get(messageKey); + if (candidate?.groupKind === groupKind && !claimed.has(candidate)) { + overlaps.set(candidate, (overlaps.get(candidate) ?? 0) + 1); + } + } + let match: PaneItemIdentity | undefined; + let largestOverlap = 0; + for (const [candidate, count] of overlaps) { + if (count > largestOverlap) { + match = candidate; + largestOverlap = count; + } + } + if (messageKeys.length === 0) { + match = previous.find((identity) => identity.groupKind === groupKind + && identity.messageKeys.length === 0 && !claimed.has(identity)); + } + if (match) { + claimed.add(match); + return { key: match.key, groupKind, messageKeys }; + } + + const baseKey = `${groupKind}-${messageKeys[0] ?? 'pending'}`; + let key = baseKey; + let suffix = 1; + while (reservedKeys.has(key)) key = `${baseKey}__${suffix++}`; + reservedKeys.add(key); + return { key, groupKind, messageKeys }; + }); +} diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 54d900cb..55339498 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -337,6 +337,7 @@ function ChatInterface({ <> ; + preserveScrollPosition?: boolean; onWheel: () => void; onTouchMove: () => void; isLoadingSessionMessages: boolean; @@ -44,9 +48,10 @@ interface ChatMessagesPaneProps { currentSessionId: string | null; provider: LLMProvider; isLoadingMoreMessages: boolean; + historyLoadError?: boolean; + retryOlderMessages?: () => void; hasMoreMessages: boolean; totalMessages: number; - sessionMessagesCount: number; visibleMessageCount: number; visibleMessages: ChatMessage[]; loadEarlierMessages: () => void; @@ -65,6 +70,7 @@ interface ChatMessagesPaneProps { function ChatMessagesPane({ scrollContainerRef, + preserveScrollPosition = false, onWheel, onTouchMove, isLoadingSessionMessages, @@ -76,9 +82,10 @@ function ChatMessagesPane({ currentSessionId, provider, isLoadingMoreMessages, + historyLoadError = false, + retryOlderMessages, hasMoreMessages, totalMessages, - sessionMessagesCount, visibleMessageCount, visibleMessages, loadEarlierMessages, @@ -98,7 +105,7 @@ function ChatMessagesPane({ const displayProvider = selectedSession?.provider ?? selectedSession?.__provider ?? provider; // Realtime rows can outgrow the last persisted count; that total then says // nothing about how many older messages remain to be loaded. - const canShowTotal = totalMessages > 0 && sessionMessagesCount <= totalMessages; + const canShowTotal = totalMessages > 0 && chatMessages.length <= totalMessages; // The live turn has a block from the moment it starts (empty until its first // call), so the run's status has one place in the transcript. Where blocks // are off, a bare running row stands in that place instead. @@ -106,126 +113,143 @@ function ChatMessagesPane({ () => buildPaneList(visibleMessages, density, { running: isProcessing }), [visibleMessages, density, isProcessing], ); - const showInlineRunningRow = isProcessing && !toolOutputDensityRules(density).workBlock; + const densityRules = toolOutputDensityRules(density); + const showInlineRunningRow = isProcessing && !densityRules.workBlock; // Keys for the top-level items; each fold assigns its own inside. The key // function is not handed down: it is new whenever the list changes, which // is every streamed delta, and a prop that changes every delta would make // every folded block below re-render for an answer streaming above it. const getMessageKey = useMemo(() => assignMessageKeys(visibleMessages), [visibleMessages]); + const sessionKey = selectedSession?.id ?? currentSessionId; + const [identityState, setIdentityState] = useState(() => ({ + paneItems, + density, + sessionKey, + identities: reconcilePaneItemIdentities(paneItems, density, getMessageKey), + })); + let paneIdentities = identityState.identities; + if (identityState.paneItems !== paneItems || identityState.density !== density || identityState.sessionKey !== sessionKey) { + paneIdentities = reconcilePaneItemIdentities( + paneItems, + density, + getMessageKey, + identityState.sessionKey === sessionKey ? identityState.identities : [], + ); + // Render-state reconciliation is discarded with an abandoned render; a + // mutable ref/map updated during rendering could leak uncommitted keys. + setIdentityState({ paneItems, density, sessionKey, identities: paneIdentities }); + } return ( -
-
- {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? ( -
-
-
-

{t('session.loading.sessionMessages')}

-
-
- ) : chatMessages.length === 0 ? ( - - ) : ( - <> - {/* Loading indicator for older messages (hide when load-all is active) */} - {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && ( -
-
-
-

{t('session.loading.olderMessages')}

+
+
+ +
+ {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? ( +
+
+
+

{t('session.loading.sessionMessages')}

+
-
- )} - - {/* Indicator showing there are more messages to load (hide when all loaded) */} - {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && ( -
- - {canShowTotal - ? t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages }) - : t('session.messages.loadedCount', { shown: sessionMessagesCount })}{' '} - {t('session.messages.scrollToLoad')} - -
- )} - - + ) : chatMessages.length === 0 ? ( + + ) : ( + <> + {!hasMoreMessages && chatMessages.length > visibleMessageCount ? ( +
+ + {' | '} + +
+ ) : null} - {/* Legacy message count indicator (for non-paginated view) */} - {!hasMoreMessages && chatMessages.length > visibleMessageCount && ( -
- {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} | - - {' | '} - -
- )} + {paneItems.map((item, index) => { + // Hidden thoughts never had a row; an empty wrapper would + // introduce spacing and a non-visible scroll anchor for them. + if (!isTurnWorkBlockItem(item) && !isToolGroupItem(item) && item.isThinking && !densityRules.showReasoning) return null; + const before = index > 0 ? lastMessageOf(paneItems[index - 1]) : null; + const stablePaneKey = paneIdentities[index].key; + const renderProps = { + prevMessage: before, + createDiff, + onFileOpen, + onShowSettings, + density, + showImagePreviews, + selectedProject, + provider: displayProvider, + }; + return ( +
+ {isTurnWorkBlockItem(item) ? ( + + ) : isToolGroupItem(item) ? ( + + ) : ( + + )} +
+ ); + })} - {paneItems.map((item, index) => { - const before = index > 0 ? lastMessageOf(paneItems[index - 1]) : null; - if (isTurnWorkBlockItem(item)) { - return ( - - ); - } - return ( - - ); - })} + {showInlineRunningRow && ( +
+ +
+ )} + + )} +
+
+
+ {historyLoadError && ( +
+
+ {t('session.loading.olderMessagesFailed')} + +
+
- {showInlineRunningRow && ( - - )} - )} -
+ {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && ( +
+
+
+ {t('session.loading.olderMessages')} +
+
+ )} + {chatMessages.length > 0 && ( + + )}
); } diff --git a/src/components/chat/view/ChatScrollAnchor.dom.bun.test.tsx b/src/components/chat/view/ChatScrollAnchor.dom.bun.test.tsx new file mode 100644 index 00000000..52e3f56d --- /dev/null +++ b/src/components/chat/view/ChatScrollAnchor.dom.bun.test.tsx @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { useCallback, useRef } from 'react'; + +import ChatScrollAnchor from './ChatScrollAnchor'; + +const nativeObserver = globalThis.ResizeObserver; +const observers = new Set(); +class TestObserver { + constructor(private callback: ResizeObserverCallback) { observers.add(callback); } + observe() { observers.add(this.callback); } + disconnect() { observers.delete(this.callback); } +} +globalThis.ResizeObserver = TestObserver as unknown as typeof ResizeObserver; +afterEach(() => { cleanup(); observers.clear(); }); +after(() => { globalThis.ResizeObserver = nativeObserver; }); + +type Row = { id: string; height: number }; +const rect = (top: number, height: number) => ({ top, bottom: top + height, height, left: 0, right: 200, width: 200, x: 0, y: top, toJSON() {} }); + +function Harness({ rows, enabled = true, session = 's', grouped = false }: { rows: Row[]; enabled?: boolean; session?: string; grouped?: boolean }) { + const container = useRef(null); + const attach = useCallback((node: HTMLDivElement | null) => { + container.current = node; + if (node) node.getBoundingClientRect = () => rect(0, 100); + }, []); + return
+ +
{ + if (node) node.getBoundingClientRect = () => rect(-(container.current?.scrollTop ?? 0), + Array.from(node.children).reduce((total, child) => total + Number((child as HTMLElement).dataset.height), 0)); + }}>{rows.map(row =>
{ + if (!node) return; + node.getBoundingClientRect = () => { + let top = -(container.current?.scrollTop ?? 0); + for (const sibling of Array.from(node.parentElement?.children ?? [])) { + if (sibling === node) break; + top += Number((sibling as HTMLElement).dataset.height); + } + return rect(top, Number(node.dataset.height)); + }; + }}>{row.id}
)}
+
+
; +} + +const initial: Row[] = [{ id: 'a', height: 100 }, { id: 'b', height: 100 }, { id: 'c', height: 100 }]; + +async function setup(grouped = false, enabled = true) { + const view = render(); + await act(async () => { await new Promise(requestAnimationFrame); }); + const pane = view.getByTestId('pane'); + pane.scrollTop = 80; + fireEvent.scroll(pane); + return { ...view, pane }; +} + +test('snapshots the current viewport at commit, excluding concurrent tail growth', async () => { + const { rerender, pane } = await setup(); + pane.scrollTop = 30; + // Even before the next scroll event, the current position—not request-time 80—is kept. + rerender(); + assert.equal(pane.scrollTop, 230); +}); + +test('preserves the visible child when older messages extend an expanded group', async () => { + const { rerender, pane } = await setup(true); + const child = pane.querySelector('[data-message-anchor="a"]'); + const group = pane.querySelector('[data-scroll-anchor]'); + rerender(); + assert.equal(pane.querySelector('[data-scroll-anchor]'), group); + assert.equal(pane.querySelector('[data-message-anchor="a"]'), child); + assert.equal(pane.scrollTop, 280); +}); + +test('keeps a nested message stable during late sizing above it', async () => { + const { rerender, pane } = await setup(true); + rerender(); + pane.querySelector('[data-message-anchor="older"]')!.dataset.height = '260'; + act(() => { + for (const callback of observers) callback([], {} as ResizeObserver); + }); + assert.equal(pane.scrollTop, 340); +}); + +test('captures before a prepend even when following is disabled in that same commit', async () => { + const { rerender, pane } = await setup(false, false); + rerender(); + assert.equal(pane.scrollTop, 280); +}); + +test('tail-only updates and unchanged pages do not move the reading position', async () => { + const { rerender, pane } = await setup(); + rerender(); + assert.equal(pane.scrollTop, 80); + rerender(); + assert.equal(pane.scrollTop, 80); +}); + +test('accounts for height decreases above the anchor', async () => { + const { rerender, pane } = await setup(); + pane.scrollTop = 150; + fireEvent.scroll(pane); + rerender(); + assert.equal(pane.scrollTop, 90); +}); + +test('does not restore an anchor across a session switch or while following', async () => { + const { rerender, pane } = await setup(); + rerender(); + assert.equal(pane.scrollTop, 80); + rerender(); + assert.equal(pane.scrollTop, 80); +}); + +test('compensates late row measurement without undoing intervening user scroll', async () => { + const { rerender, pane } = await setup(); + rerender(); + const older = pane.querySelector('[data-scroll-anchor="older"]')!; + pane.scrollTop -= 20; + older.dataset.height = '260'; + act(() => { + for (const callback of observers) callback([], {} as ResizeObserver); + }); + assert.equal(pane.scrollTop, 320); +}); diff --git a/src/components/chat/view/ChatScrollAnchor.tsx b/src/components/chat/view/ChatScrollAnchor.tsx new file mode 100644 index 00000000..061fbd21 --- /dev/null +++ b/src/components/chat/view/ChatScrollAnchor.tsx @@ -0,0 +1,142 @@ +import { Component } from 'react'; +import type { ReactNode, RefObject } from 'react'; + +type Props = { + scrollContainerRef: RefObject; + sessionKey: string | null; + enabled: boolean; + children: ReactNode; +}; + +type Anchor = { element: HTMLElement; key: string; attribute: 'data-scroll-anchor' | 'data-message-anchor'; offset: number; scrollTop: number }; + +/** A commit snapshot is required here: an effect cleanup runs after DOM mutation. */ +export default class ChatScrollAnchor extends Component, Anchor | null> { + private node: HTMLDivElement | null = null; + private observer: ResizeObserver | null = null; + private anchor: Anchor | null = null; + private frame: number | null = null; + private previousOverflowAnchor = ''; + + componentDidMount() { + this.connect(); + // The containing DOM node's ref can attach after this child's mount lifecycle. + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.connect(); + }); + } + + getSnapshotBeforeUpdate(previous: Props): Anchor | null { + if (!this.props.enabled || previous.sessionKey !== this.props.sessionKey) return null; + const snapshot = this.capture(); + if (this.node && this.observer) this.node.style.overflowAnchor = 'none'; + return snapshot; + } + + componentDidUpdate(previous: Props, _state: Record, snapshot: Anchor | null) { + this.connect(); + if (previous.sessionKey !== this.props.sessionKey || !this.props.enabled) this.anchor = null; + this.anchor = snapshot ? this.restore(snapshot) : this.props.enabled ? this.capture() : null; + } + + componentWillUnmount() { + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.disconnect(); + } + + private disconnect() { + this.observer?.disconnect(); + this.observer = null; + if (this.node) { + this.node.removeEventListener('scroll', this.onScroll); + this.node.style.overflowAnchor = this.previousOverflowAnchor; + } + this.node = null; + this.anchor = null; + } + + private connect() { + const node = this.props.scrollContainerRef.current; + if (this.node !== node) { + this.disconnect(); + this.node = node; + if (node) { + this.previousOverflowAnchor = node.style.overflowAnchor; + node.addEventListener('scroll', this.onScroll, { passive: true }); + if (typeof ResizeObserver !== 'undefined') this.observer = new ResizeObserver(this.onResize); + } + } + if (!node) return; + // App-owned anchoring includes late height changes; do not double-correct them. + node.style.overflowAnchor = this.props.enabled && this.observer ? 'none' : this.previousOverflowAnchor; + this.observeContent(); + if (!this.anchor && this.props.enabled) this.anchor = this.capture(); + } + + private observeContent() { + this.observer?.disconnect(); + if (!this.node || !this.observer || !this.props.enabled) return; + this.observer.observe(this.node); + if (this.node.firstElementChild) this.observer.observe(this.node.firstElementChild); + for (const row of this.node.querySelectorAll('[data-scroll-anchor]')) this.observer.observe(row); + } + + private capture(): Anchor | null { + const node = this.node ?? this.props.scrollContainerRef.current; + if (!node) return null; + const rows = node.querySelectorAll('[data-scroll-anchor]'); + const viewport = node.getBoundingClientRect(); + const group = this.firstVisible(rows, viewport); + if (!group) return null; + // A group can retain its DOM while prepending children inside it. Preserve + // the message the reader sees, not the enclosing group's unchanged top. + const message = this.firstVisible(group.querySelectorAll('[data-message-anchor]'), viewport); + const element = message ?? group; + const attribute = message ? 'data-message-anchor' : 'data-scroll-anchor'; + const rect = element.getBoundingClientRect(); + return { element, key: element.getAttribute(attribute)!, attribute, offset: rect.top - viewport.top, scrollTop: node.scrollTop }; + } + + private firstVisible(rows: NodeListOf, viewport: DOMRect): HTMLElement | null { + // Each list contains non-nested siblings in transcript order. Binary search + // only within the visible group avoids measuring all hidden tool output. + let low = 0; + let high = rows.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (rows[middle].getBoundingClientRect().bottom <= viewport.top) low = middle + 1; + else high = middle; + } + const element = rows[low]; + if (!element || element.getBoundingClientRect().top >= viewport.bottom) return null; + return element; + } + + private restore(saved: Anchor): Anchor | null { + const node = this.node; + if (!node) return null; + const element = node.contains(saved.element) ? saved.element + : Array.from(node.querySelectorAll(`[${saved.attribute}]`)).find(row => row.getAttribute(saved.attribute) === saved.key); + if (!element) return this.capture(); + const offset = element.getBoundingClientRect().top - node.getBoundingClientRect().top; + // A wheel/scrollbar move can precede its scroll event. Keep that user movement. + const wanted = saved.offset - (node.scrollTop - saved.scrollTop); + const change = offset - wanted; + if (Math.abs(change) > 0.5) node.scrollTop += change; + return { element, key: saved.key, attribute: saved.attribute, offset: wanted, scrollTop: node.scrollTop }; + } + + private onScroll = () => { + if (this.props.enabled && this.node?.scrollTop !== this.anchor?.scrollTop) this.anchor = this.capture(); + }; + + private onResize = () => { + if (!this.props.enabled) return; + this.anchor = this.anchor ? this.restore(this.anchor) : this.capture(); + }; + + render() { + return this.props.children; + } +} diff --git a/src/components/chat/view/GroupedMessageList.tsx b/src/components/chat/view/GroupedMessageList.tsx index 63735216..83c60773 100644 --- a/src/components/chat/view/GroupedMessageList.tsx +++ b/src/components/chat/view/GroupedMessageList.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react'; import type { ReactNode } from 'react'; import type { ChatMessage, CodeEditorDiffInfo, Provider } from '../types/types'; import type { Project } from '../../../types/app'; import { assignMessageKeys } from '../utils/messageKeys'; +import { reconcilePaneItemIdentities } from '../utils/paneItemIdentity'; import { isToolGroupItem } from '../utils/toolGrouping'; import type { MessageListItem } from '../utils/toolGrouping'; +import { DEFAULT_TOOL_OUTPUT_DENSITY } from '../utils/toolOutputDensity'; import type { ToolOutputDensity } from '../utils/toolOutputDensity'; import MessageComponent from './MessageComponent'; @@ -35,18 +38,28 @@ interface GroupedMessageListProps extends MessageRenderProps { * cards inside a folded turn are exactly the cards the pane would have shown. */ export default function GroupedMessageList({ items, prevMessage, ...renderProps }: GroupedMessageListProps) { - const { density } = renderProps; + const { density = DEFAULT_TOOL_OUTPUT_DENSITY } = renderProps; const getMessageKey = assignMessageKeys(items.flatMap((item) => (isToolGroupItem(item) ? item.messages : [item]))); + const [identityState, setIdentityState] = useState(() => ({ + items, + density, + identities: reconcilePaneItemIdentities(items, density, getMessageKey), + })); + let identities = identityState.identities; + if (identityState.items !== items || identityState.density !== density) { + identities = reconcilePaneItemIdentities(items, density, getMessageKey, identityState.identities); + setIdentityState({ items, density, identities }); + } const rendered: ReactNode[] = []; let previous = prevMessage; - for (const item of items) { + for (const [index, item] of items.entries()) { const before = previous; if (isToolGroupItem(item)) { previous = item.messages[item.messages.length - 1] || previous; rendered.push( @@ -54,7 +55,7 @@ export default function LoadAllMessagesOverlay({ disabled={isLoadingAllMessages} > {isLoadingAllMessages && ( -
+
)} {isLoadingAllMessages diff --git a/src/components/chat/view/MessageComponent.tsx b/src/components/chat/view/MessageComponent.tsx index 08458447..35659d67 100644 --- a/src/components/chat/view/MessageComponent.tsx +++ b/src/components/chat/view/MessageComponent.tsx @@ -8,6 +8,7 @@ import type { Provider, ToolResult, CodeEditorDiffInfo } from '../types/types'; +import { getIntrinsicMessageKey } from '../utils/messageKeys'; import { formatUsageLimitText } from '../utils/chatFormatting'; import { toolOutputDensityRules } from '../utils/toolOutputDensity'; import type { ToolOutputDensity } from '../utils/toolOutputDensity'; @@ -160,6 +161,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, d
{message.type === 'user' ? ( diff --git a/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx b/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx index f74af8ca..69c837f7 100644 --- a/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx +++ b/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx @@ -7,6 +7,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea import '../../../i18n/config'; import enChat from '../../../i18n/locales/en/chat.json'; import type { ChatMessage } from '../types/types'; +import { assignMessageKeys } from '../utils/messageKeys'; import type { ToolOutputDensity } from '../utils/toolOutputDensity'; import ChatMessagesPane from './ChatMessagesPane'; @@ -21,6 +22,7 @@ afterEach(cleanup); const at = (seconds: number) => new Date(Date.UTC(2026, 8, 2, 0, 0, seconds)).toISOString(); const call = (toolName: string, seconds: number, toolInput: unknown, isError = false): ChatMessage => ({ + id: `row-${toolName}-${seconds}`, type: 'assistant', content: '', timestamp: at(seconds), isToolUse: true, toolName, toolInput, toolId: `${toolName}-${seconds}`, toolResult: { content: isError ? 'exit 1: missing module' : 'fine', isError, timestamp: at(seconds + 1) }, }); @@ -45,7 +47,6 @@ const paneProps = (density: ToolOutputDensity, messages = transcript) => ({ isLoadingMoreMessages: false, hasMoreMessages: false, totalMessages: messages.length, - sessionMessagesCount: messages.length, visibleMessageCount: messages.length, visibleMessages: messages, loadEarlierMessages: () => {}, @@ -111,9 +112,89 @@ test('compact folds the block too, and its failure stays folded inside once open assert.equal(within(body).queryByText(/exit 1: missing module/), null); }); +for (const density of ['balanced', 'compact'] as const) { + test(`${density}: an expanded top work block keeps its DOM and anchor through continuous prepend and append`, () => { + const messages = [call('read', 2, { path: 'src/beta.ts' }), call('read', 3, { path: 'src/gamma.ts' })]; + const view = render(createElement(ChatMessagesPane, paneProps(density, messages))); + const button = toggle(); + fireEvent.click(button); + const block = button.closest('[data-work-block]'); + const anchor = button.closest('[data-scroll-anchor]'); + const key = anchor?.getAttribute('data-scroll-anchor'); + const bodyId = button.getAttribute('aria-controls'); + const body = document.getElementById(bodyId ?? ''); + assert.ok(block); + assert.ok(anchor); + assert.ok(key); + assert.ok(body); + const groupedReads = within(body).getByRole('button', { expanded: false }); + fireEvent.click(groupedReads); + assert.equal(groupedReads.getAttribute('aria-expanded'), 'true'); + const nestedGroup = groupedReads.closest('.chat-message'); + const betaCard = within(body).getByText('beta.ts').closest('.chat-message'); + assert.ok(nestedGroup); + assert.ok(betaCard); + + // Rebuilt message objects still represent the same normalized rows. + const prepended = [call('read', 1, { path: 'src/alpha.ts' }), ...messages.map((message) => ({ ...message }))]; + const appended = [...prepended.map((message) => ({ ...message })), call('read', 4, { path: 'src/delta.ts' })]; + for (const updated of [prepended, appended]) { + view.rerender(createElement(ChatMessagesPane, paneProps(density, updated))); + assert.equal(toggle(), button, 'the expanded toggle is not remounted'); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(button.closest('[data-work-block]'), block); + assert.equal(button.closest('[data-scroll-anchor]'), anchor); + assert.equal(anchor.getAttribute('data-scroll-anchor'), key); + assert.equal(button.getAttribute('aria-controls'), bodyId); + assert.equal(document.getElementById(bodyId ?? ''), body, 'the expanded body is not replaced'); + assert.equal(within(body).getByRole('button', { expanded: true }), groupedReads, 'the nested group toggle is not remounted'); + assert.equal(groupedReads.getAttribute('aria-expanded'), 'true'); + assert.equal(groupedReads.closest('.chat-message'), nestedGroup); + assert.equal(within(body).getByText('beta.ts').closest('.chat-message'), betaCard, 'an existing nested card keeps its DOM'); + assert.ok(within(body).getByText('alpha.ts')); + assert.match(button.textContent ?? '', new RegExp(`${updated.length} files read`)); + assert.equal(view.container.querySelectorAll('[data-scroll-anchor]').length, 1); + } + + assert.ok(within(body).getByText('delta.ts')); + fireEvent.click(groupedReads); + assert.equal(groupedReads.getAttribute('aria-expanded'), 'false'); + assert.equal(within(body).queryByText('beta.ts'), null); + }); +} + +test('a disjoint older work block receives its own anchor without stealing the expanded block DOM', () => { + const messages = [call('read', 4, { path: 'src/current.ts' })]; + const view = render(createElement(ChatMessagesPane, paneProps('balanced', messages))); + const button = toggle(); + fireEvent.click(button); + const anchor = button.closest('[data-scroll-anchor]'); + const key = anchor?.getAttribute('data-scroll-anchor'); + const olderMessages: ChatMessage[] = [ + call('bash', 1, { command: 'pwd' }), + { id: 'separator', type: 'assistant', content: 'Now inspect the file.', timestamp: at(2) }, + ...messages.map((message) => ({ ...message })), + ]; + view.rerender(createElement(ChatMessagesPane, paneProps('balanced', olderMessages))); + const blocks = screen.getAllByRole('button', { name: enChat.workBlock.toggle }); + assert.equal(blocks.length, 2); + assert.equal(blocks[0].getAttribute('aria-expanded'), 'false'); + assert.equal(blocks[1], button); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(button.closest('[data-scroll-anchor]'), anchor); + assert.equal(anchor?.getAttribute('data-scroll-anchor'), key); + const anchors = Array.from(view.container.querySelectorAll('[data-scroll-anchor]')); + assert.equal(anchors.length, 3); + assert.equal(new Set(anchors.map((row) => row.getAttribute('data-scroll-anchor'))).size, 3); + assert.equal(anchors[1].getAttribute('data-scroll-anchor'), assignMessageKeys(olderMessages)(olderMessages[1])); +}); + test('switching to detailed removes the block and puts the cards back at the top level; switching back restores it', () => { const view = render(createElement(ChatMessagesPane, paneProps('balanced'))); - assert.ok(toggle()); + const initialButton = toggle(); + fireEvent.click(initialButton); + assert.equal(initialButton.getAttribute('aria-expanded'), 'true'); + const initialAnchor = initialButton.closest('[data-scroll-anchor]'); view.rerender(createElement(ChatMessagesPane, paneProps('detailed'))); assert.equal(screen.queryByRole('button', { name: enChat.workBlock.toggle }), null); @@ -122,10 +203,112 @@ test('switching to detailed removes the block and puts the cards back at the top assert.ok(screen.getByText('beta.ts')); assert.ok(screen.getByText(/exit 1: missing module/)); assert.equal(screen.queryByText(/×2/), null); + assert.equal(initialAnchor?.isConnected, false); + assert.deepEqual( + Array.from(view.container.querySelectorAll('[data-scroll-anchor]'), (row) => row.getAttribute('data-scroll-anchor')), + transcript.map(assignMessageKeys(transcript)), + ); view.rerender(createElement(ChatMessagesPane, paneProps('compact'))); assert.equal(toggle().getAttribute('aria-expanded'), 'false'); + assert.notEqual(toggle(), initialButton); assert.equal(screen.queryByText('alpha.ts'), null); + + const compactButton = toggle(); + const compactAnchor = compactButton.closest('[data-scroll-anchor]'); + fireEvent.click(compactButton); + assert.equal(compactButton.getAttribute('aria-expanded'), 'true'); + view.rerender(createElement(ChatMessagesPane, paneProps('balanced'))); + assert.equal(toggle().getAttribute('aria-expanded'), 'false'); + assert.notEqual(toggle(), compactButton); + assert.notEqual(toggle().closest('[data-scroll-anchor]')?.getAttribute('data-scroll-anchor'), compactAnchor?.getAttribute('data-scroll-anchor')); +}); + +test('ordinary rows keep their message ID anchors after prepend and content updates; hidden thoughts have no empty rows', () => { + const answer: ChatMessage = { id: 'answer', type: 'assistant', content: 'Initial answer.', timestamp: at(5) }; + const view = render(createElement(ChatMessagesPane, paneProps('balanced', [answer]))); + const messageNode = screen.getByText('Initial answer.').closest('.chat-message'); + const anchor = messageNode?.closest('[data-scroll-anchor]'); + assert.ok(messageNode); + assert.ok(anchor); + const changed = { ...answer, content: 'Updated answer.', timestamp: at(6) }; + const messages: ChatMessage[] = [ + { id: 'question', type: 'user', content: 'Explain.', timestamp: at(0) }, + { id: 'thought', type: 'assistant', content: 'Hidden thought.', isThinking: true, timestamp: at(1) }, + changed, + ]; + view.rerender(createElement(ChatMessagesPane, paneProps('balanced', messages))); + assert.equal(screen.getByText('Updated answer.').closest('.chat-message'), messageNode); + assert.equal(messageNode.closest('[data-scroll-anchor]'), anchor); + assert.equal(anchor.getAttribute('data-scroll-anchor'), assignMessageKeys(messages)(changed)); + assert.equal(view.container.querySelectorAll('[data-scroll-anchor]').length, 2); + assert.equal(screen.queryByText('Hidden thought.'), null); +}); + +test('history shows only an active loading indicator, without an idle count barrier', () => { + let loadAllCalls = 0; + let loadEarlierCalls = 0; + const props = { + ...paneProps('balanced'), + hasMoreMessages: true, + allMessagesLoaded: false, + totalMessages: 50, + loadAllMessages: () => { loadAllCalls += 1; }, + loadEarlierMessages: () => { loadEarlierCalls += 1; }, + }; + const view = render(createElement(ChatMessagesPane, props)); + const scrollPane = view.container.querySelector('.chat-messages-pane')!; + const anchor = scrollPane.querySelector('[data-scroll-anchor]'); + assert.ok(anchor); + assert.equal(view.container.querySelector('[data-pagination-status]'), null); + assert.ok(!scrollPane.textContent?.includes(enChat.session.messages.scrollToLoad)); + assert.equal(view.container.querySelector('[data-load-all-overlay]'), null); + + view.rerender(createElement(ChatMessagesPane, { ...props, isLoadingMoreMessages: true })); + const status = view.container.querySelector('[data-pagination-status]'); + assert.equal(status?.textContent, enChat.session.loading.olderMessages); + assert.equal(status?.parentElement, scrollPane.parentElement); + assert.equal(scrollPane.contains(status), false, 'loading does not displace the first message'); + assert.equal(scrollPane.querySelector('[data-scroll-anchor]'), anchor); + assert.equal(view.container.querySelector('[data-load-all-overlay]'), null); + + view.rerender(createElement(ChatMessagesPane, props)); + assert.equal(view.container.querySelector('[data-pagination-status]'), null); + assert.equal(scrollPane.querySelector('[data-scroll-anchor]'), anchor); + + view.rerender(createElement(ChatMessagesPane, { ...props, isLoadingAllMessages: true })); + const overlay = view.container.querySelector('[data-load-all-overlay]')!; + assert.ok(overlay); + assert.equal(scrollPane.contains(overlay), false); + const loadingButton = within(overlay as HTMLElement).getByRole('button') as HTMLButtonElement; + assert.equal(loadingButton.disabled, true); + assert.equal(loadingButton.textContent, enChat.session.messages.loadingAll); + + view.rerender(createElement(ChatMessagesPane, { ...props, hasMoreMessages: false, allMessagesLoaded: true })); + assert.equal(view.container.querySelector('[data-load-all-overlay]'), null); + assert.equal(view.container.querySelector('[data-pagination-status]'), null); + assert.equal(scrollPane.querySelector('[data-scroll-anchor]'), anchor); + + view.rerender(createElement(ChatMessagesPane, { ...props, hasMoreMessages: false, visibleMessageCount: 2 })); + fireEvent.click(within(scrollPane as HTMLElement).getByRole('button', { name: enChat.session.messages.loadEarlier })); + fireEvent.click(within(scrollPane as HTMLElement).getByRole('button', { name: enChat.session.messages.loadAll })); + assert.equal(loadEarlierCalls, 1); + assert.equal(loadAllCalls, 1); +}); + +test('a history failure stays visible with an explicit retry instead of flashing a spinner', () => { + let retries = 0; + const props = { ...paneProps('balanced'), hasMoreMessages: true, allMessagesLoaded: false, + historyLoadError: true, retryOlderMessages: () => { retries += 1; } }; + const view = render(createElement(ChatMessagesPane, props)); + assert.equal(view.container.querySelector('[data-pagination-status]'), null); + const alert = screen.getByRole('alert'); + assert.ok(alert.textContent?.includes(enChat.session.loading.olderMessagesFailed)); + fireEvent.click(within(alert).getByRole('button', { name: enChat.session.loading.retry })); + assert.equal(retries, 1); + view.rerender(createElement(ChatMessagesPane, { ...props, historyLoadError: false, isLoadingMoreMessages: true })); + assert.equal(screen.queryByRole('alert'), null); + assert.ok(view.container.querySelector('[data-pagination-status]')); }); test('a live turn before its first tool call is a Thinking row, not a finished block', () => { diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 9dc75d75..1658ac83 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "Retrieving earlier messages…", + "olderMessagesFailed": "Earlier messages could not be retrieved.", + "retry": "Retry", "sessionMessages": "Retrieving conversation messages…" }, "messages": { diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 5a216c6e..671c1729 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "이전 메시지 로딩 중...", + "olderMessagesFailed": "이전 메시지를 불러오지 못했습니다.", + "retry": "다시 시도", "sessionMessages": "대화 메시지 로딩 중..." }, "messages": { diff --git a/src/index.css b/src/index.css index 8a16cb24..190a48ee 100644 --- a/src/index.css +++ b/src/index.css @@ -561,12 +561,11 @@ .chat-message { hyphens: auto; overflow-wrap: break-word; word-wrap: break-word; - contain-intrinsic-size: auto 180px; content-visibility: auto; contain: layout style paint; + /* Pagination anchors use actual geometry. Estimated offscreen heights + changed the scroll range a second time after every prepend. */ + contain: layout style paint; } - .chat-message.assistant { contain-intrinsic-size: auto 240px; } - .chat-message.user, .chat-message.tool, .chat-message.error { contain-intrinsic-size: auto 96px; } - .chat-message pre, .chat-message code { white-space: pre-wrap !important; diff --git a/src/stores/sessionMessageFetch.test.ts b/src/stores/sessionMessageFetch.test.ts index a5783630..4bc32ebe 100644 --- a/src/stores/sessionMessageFetch.test.ts +++ b/src/stores/sessionMessageFetch.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { REFRESH_RECONCILE_MIN_MESSAGES, buildRefreshMessagesUrl } from './sessionMessageFetch'; +import { QueryClient } from '@tanstack/react-query'; + +import { REFRESH_RECONCILE_MIN_MESSAGES, buildRefreshMessagesUrl, shareMessageWindow } from './sessionMessageFetch'; test('buildRefreshMessagesUrl always includes a bounded limit (never unbounded)', () => { const url = buildRefreshMessagesUrl('sess-1', 0); @@ -36,3 +38,104 @@ test('buildRefreshMessagesUrl omits image data only when previews are disabled', const withoutImages = new URL(buildRefreshMessagesUrl('s', 0, false), 'http://x').searchParams; assert.equal(withoutImages.get('includeImages'), 'false'); }); + +test('shareMessageWindow preserves existing row references across prepend in the query cache', () => { + const client = new QueryClient(); + client.setQueryDefaults(['messages'], { structuralSharing: shareMessageWindow }); + const key = ['messages', 'session']; + const recent = { id: 'recent', content: 'recent', toolInput: { path: 'recent.ts' } }; + const latest = { id: 'latest', content: 'latest', toolInput: { path: 'latest.ts' } }; + const oldWindow = { messages: [recent, latest], total: 3, hasMore: true, offset: 2 }; + const older = { id: 'older', content: 'older', toolInput: { path: 'older.ts' } }; + const newWindow = { messages: [older, recent, latest], total: 3, hasMore: false, offset: 3 }; + + try { + client.setQueryData(key, oldWindow); + client.setQueryData(key, newWindow); + const shared = client.getQueryData(key)!; + + assert.deepEqual(shared, newWindow); + assert.equal(shared.messages[0], older); + assert.equal(shared.messages[1], recent); + assert.equal(shared.messages[2], latest); + assert.deepEqual(oldWindow.messages, [recent, latest], 'sharing must not mutate the prior window'); + } finally { + client.clear(); + } +}); + +test('shareMessageWindow matches refreshed rows by ID after a prepend, retaining unchanged metadata', () => { + const oldWindow = { + messages: [{ id: 'recent', content: 'same', images: [{ path: 'image.png' }] }], + total: 3, hasMore: true, offset: 1, tokenUsage: { input: 100 }, + }; + const refreshed = { + messages: [{ id: 'older', content: 'older', images: [] }, structuredClone(oldWindow.messages[0])], + total: 3, hasMore: true, offset: 2, tokenUsage: { input: 100 }, + }; + const shared = shareMessageWindow(oldWindow, refreshed) as typeof refreshed; + + assert.deepEqual(shared, refreshed); + assert.equal(shared.messages[0], refreshed.messages[0]); + assert.equal(shared.messages[1], oldWindow.messages[0]); + assert.equal(shared.tokenUsage, oldWindow.tokenUsage); + assert.equal(shareMessageWindow(shared, structuredClone(shared)), shared, 'an unchanged refresh reuses the whole window'); + + const metadataOnly = shareMessageWindow(shared, { ...structuredClone(shared), total: 4 }) as typeof shared; + assert.equal(metadataOnly.total, 4); + assert.equal(metadataOnly.messages, shared.messages, 'metadata changes do not replace unchanged rows or their array'); +}); + +test('shareMessageWindow replaces changed same-ID rows while retaining equal nested fields', () => { + const original = { id: 'call', content: 'before', toolInput: { path: 'file.ts' }, toolResult: { content: 'old', isError: false } }; + const untouched = { ...original, id: 'untouched', content: 'same' }; + const oldWindow = { messages: [original, untouched], total: 2, hasMore: false, offset: 2 }; + const updated = { ...structuredClone(original), content: 'after', toolResult: { content: 'new', isError: true } }; + const newWindow = { ...oldWindow, messages: [updated, structuredClone(untouched)] }; + const shared = shareMessageWindow(oldWindow, newWindow) as typeof newWindow; + + assert.deepEqual(shared, newWindow); + assert.notEqual(shared.messages[0], original); + assert.equal(shared.messages[0].content, 'after'); + assert.equal(shared.messages[0].toolInput, original.toolInput); + assert.deepEqual(shared.messages[0].toolResult, updated.toolResult); + assert.equal(shared.messages[1], untouched); + assert.equal(original.content, 'before'); + assert.equal(original.toolResult.content, 'old'); + + const withoutResult = { id: original.id, content: updated.content, toolInput: updated.toolInput }; + const removed = shareMessageWindow(shared, { ...shared, messages: [withoutResult, untouched] }) as { messages: object[] }; + assert.notEqual(removed.messages[0], shared.messages[0]); + assert.equal('toolResult' in removed.messages[0], false, 'removed row fields must not survive reconciliation'); +}); + +test('shareMessageWindow does not alias distinct rows without persisted IDs', () => { + for (const id of [undefined, '']) { + const original = { ...(id === undefined ? {} : { id }), content: 'identical' }; + const oldWindow = { messages: [original], total: 2, hasMore: true, offset: 1 }; + const prepended = { ...original }; + const newWindow = { messages: [prepended, original], total: 2, hasMore: false, offset: 2 }; + const shared = shareMessageWindow(oldWindow, newWindow) as typeof newWindow; + + assert.equal(shared.messages[0], prepended); + assert.equal(shared.messages[1], original); + assert.notEqual(shared.messages[0], shared.messages[1]); + + const refreshed = structuredClone(newWindow); + const refreshedShared = shareMessageWindow(shared, refreshed) as typeof refreshed; + assert.equal(refreshedShared.messages[0], refreshed.messages[0]); + assert.equal(refreshedShared.messages[1], refreshed.messages[1]); + assert.notEqual(refreshedShared.messages[0], prepended, 'equal ID-less rows are not matched by position'); + assert.notEqual(refreshedShared.messages[1], original, 'equal ID-less rows are not matched by content'); + } +}); + +test('shareMessageWindow returns incoming data when a message window is unavailable', () => { + const window = { messages: [{ id: 'first', content: 'first' }], total: 1, hasMore: false, offset: 1 }; + for (const previous of [undefined, null, {}, { messages: null }]) { + assert.equal(shareMessageWindow(previous, window), window); + } + for (const next of [undefined, null, 1, {}, { messages: null }]) { + assert.equal(shareMessageWindow(window, next), next); + } +}); diff --git a/src/stores/sessionMessageFetch.ts b/src/stores/sessionMessageFetch.ts index f1b83b44..dd17f5c2 100644 --- a/src/stores/sessionMessageFetch.ts +++ b/src/stores/sessionMessageFetch.ts @@ -1,3 +1,5 @@ +import { replaceEqualDeep } from '@tanstack/react-query'; + // Reconcile fetch sizing for the session message store. // // refreshFromServer() re-fetches the server transcript after streaming, a WS @@ -14,6 +16,44 @@ export const REFRESH_RECONCILE_MIN_MESSAGES = 20; +function isMessageWindow(value: unknown): value is { messages: unknown[]; [key: string]: unknown } { + return value !== null && typeof value === 'object' && 'messages' in value && Array.isArray(value.messages); +} + +function persistedMessageId(value: unknown): string | null { + return value !== null && typeof value === 'object' && 'id' in value && typeof value.id === 'string' && value.id.length > 0 + ? value.id + : null; +} + +/** Query structural sharing by persisted row identity, not its position in the window. */ +export function shareMessageWindow(oldData: unknown, newData: unknown): unknown { + if (oldData === newData || !isMessageWindow(oldData) || !isMessageWindow(newData)) return newData; + + const { messages: oldMessages, ...oldFields } = oldData; + const { messages: newMessages, ...newFields } = newData; + const previousById = new Map(); + for (const message of oldMessages) { + const id = persistedMessageId(message); + if (id !== null) previousById.set(id, message); + } + + let sameMessages = oldMessages.length === newMessages.length; + const messages = newMessages.map((message, index) => { + const id = persistedMessageId(message); + const previous = id === null ? undefined : previousById.get(id); + const shared = previous === undefined ? message : replaceEqualDeep(previous, message); + if (shared !== oldMessages[index]) sameMessages = false; + return shared; + }); + + // Do not deep-share the message array by index after matching rows by ID: + // a prepend shifts indices, and ID-less rows must not alias unrelated rows. + const fields = replaceEqualDeep(oldFields, newFields); + if (sameMessages && fields === oldFields) return oldData; + return { ...fields, messages: sameMessages ? oldMessages : messages }; +} + /** * Builds the bounded reconcile URL for refreshFromServer. * diff --git a/src/stores/useSessionStore.dom.bun.test.tsx b/src/stores/useSessionStore.dom.bun.test.tsx index 4f179616..13503514 100644 --- a/src/stores/useSessionStore.dom.bun.test.tsx +++ b/src/stores/useSessionStore.dom.bun.test.tsx @@ -223,7 +223,9 @@ test('fetchMore serializes a captured offset and deduplicates only matching mess total: 4, hasMore: false, })); - await Promise.all([firstPage, duplicatePage]); + const [accepted, duplicate] = await Promise.all([firstPage, duplicatePage]); + assert.deepEqual(accepted, { addedCount: 1, hasMore: false, total: 4 }); + assert.equal(duplicate, null); const slot = store.getSessionSlot('session')!; assert.deepEqual(slot.serverMessages.map(message => message.id), ['old-1', 'new-1', 'new-2']); @@ -233,6 +235,38 @@ test('fetchMore serializes a captured offset and deduplicates only matching mess } }); +test('failed, empty and superseded older pages never report an insertion', async () => { + const originalFetch = globalThis.fetch; + const pending: PendingRequest[] = []; + globalThis.fetch = ((url: string) => new Promise((resolve) => pending.push({ url, resolve }))) as typeof fetch; + try { + const store = createStore(); + const initial = store.fetchFromServer('session', { limit: 1 }); + const saved = { id: 'saved', sessionId: 'session', timestamp: '2026-01-01T00:00:00Z', kind: 'text', provider: 'gjc' }; + pending.shift()!.resolve(response({ messages: [saved], total: 3, hasMore: true })); + await initial; + const failed = store.fetchMore('session'); + pending.shift()!.resolve(new Response('', { status: 500 })); + assert.equal(await failed, null); + assert.equal(store.getSessionSlot('session')!.offset, 1); + + const obsolete = store.fetchMore('session'); + const oldRequest = pending.shift()!; + const refresh = store.refreshFromServer('session'); + pending.shift()!.resolve(response({ messages: [saved], total: 3, hasMore: true })); + await refresh; + oldRequest.resolve(response({ messages: [{ ...saved, id: 'old' }], total: 3, hasMore: true })); + assert.equal(await obsolete, null); + + const empty = store.fetchMore('session'); + pending.shift()!.resolve(response({ messages: [], total: 1, hasMore: false })); + assert.deepEqual(await empty, { addedCount: 0, hasMore: false, total: 1 }); + assert.deepEqual(store.getMessages('session').map(row => row.id), ['saved']); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('newer accepted pagination and refresh settle loading and reset the pagination offset', async () => { const originalFetch = globalThis.fetch; const pending: PendingRequest[] = []; @@ -479,7 +513,7 @@ test('getMessages reflects a completed fetch and keeps empty reads identity-stab assert.equal(preFetch.length, 0); assert.equal(store.getMessages('session'), preFetch); - let request: Promise; + let request: ReturnType; act(() => { request = store.fetchFromServer('session', { limit: 20, offset: 0 }); }); diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 2ba7758c..a6a5f667 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -5,7 +5,7 @@ import type { JobProjectionErrorCode, JobProjectionEvent, JobSnapshot, JobState, import type { LLMProvider } from '../types/app'; import { authenticatedFetch } from '../utils/api'; -import { buildRefreshMessagesUrl } from './sessionMessageFetch'; +import { buildRefreshMessagesUrl, shareMessageWindow } from './sessionMessageFetch'; type MessageKind = 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end' | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' | 'session_created' | 'interactive_prompt' | 'task_notification' | 'system_notice'; export interface NormalizedMessage { @@ -232,6 +232,12 @@ async function reconcile(sessionId: string, slot: SessionSlot): Promise(null); + if (configuredClient.current !== queryClient) { + queryClient.setQueryDefaults(['messages'], { structuralSharing: shareMessageWindow }); + configuredClient.current = queryClient; + } const slots = useRef(new Map()); const jobs = useRef(new Map()); const activeSession = useRef(null); @@ -330,27 +336,32 @@ export function useSessionStore() { const params = new URLSearchParams(); if (options.limit !== null && options.limit !== undefined) { params.set('limit', String(options.limit)); params.set('offset', String(options.offset ?? 0)); } if (!slot._includeImages) params.set('includeImages', 'false'); const response = await authenticatedFetch(`/api/providers/sessions/${encodeURIComponent(id)}/messages${params.size ? `?${params}` : ''}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const body = await response.json(); const data = body?.data ?? body; const messages: NormalizedMessage[] = data.messages || []; - if (ticket !== slot._fetchSeq) return slot; + if (ticket !== slot._fetchSeq) return null; queryClient.setQueryData(['messages', id], { messages: withoutRepeatedIds(messages), total: data.total ?? messages.length, hasMore: Boolean(data.hasMore), offset: (options.offset ?? 0) + messages.length, tokenUsage: data.tokenUsage || slot.tokenUsage }); if (slot.status === 'loading' && slot._loadingTicket === ticket) slot.status = 'idle'; refreshMerged(slot); emitSession(id); return slot; } catch (error) { - console.error(`[SessionStore] fetch failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'error'; emitSession(id); } return slot; + console.error(`[SessionStore] fetch failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'error'; emitSession(id); } return null; } finally { slot._pendingRequests -= 1; if (slot._loadingTicket === ticket) slot._loadingTicket = null; evict(); } }, [begin, emitSession, evict, queryClient]); const fetchMore = useCallback(async (id: string, options: { limit?: number; includeImages?: boolean } = {}) => { const slot = slots.current.get(id) ?? newSlot(id, queryClient); if (typeof options.includeImages === 'boolean') slot._includeImages = options.includeImages; - if (!slot.hasMore || slot._fetchMoreTicket !== null) { remember(id, slot); return slot; } + if (!slot.hasMore || slot._fetchMoreTicket !== null) { remember(id, slot); return null; } const offset = slot.offset; const ticket = ++slot._fetchSeq; slot._fetchMoreTicket = ticket; slot._pendingRequests += 1; remember(id, slot); if (slot.status === 'loading') slot._loadingTicket = ticket; try { const params = new URLSearchParams({ limit: String(options.limit ?? 20), offset: String(offset) }); if (!slot._includeImages) params.set('includeImages', 'false'); const response = await authenticatedFetch(`/api/providers/sessions/${encodeURIComponent(id)}/messages?${params}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const body = await response.json(); const data = body?.data ?? body; const older: NormalizedMessage[] = data.messages || []; - if (ticket !== slot._fetchSeq || slot._fetchMoreTicket !== ticket || slot.offset !== offset) return slot; - queryClient.setQueryData(['messages', id], { messages: withoutRepeatedIds([...older, ...slot.serverMessages]), total: slot.total, hasMore: Boolean(data.hasMore), offset: offset + older.length, tokenUsage: slot.tokenUsage }); - if (slot.status === 'loading' && slot._loadingTicket === ticket) slot.status = 'idle'; refreshMerged(slot); emitSession(id); return slot; + if (ticket !== slot._fetchSeq || slot._fetchMoreTicket !== ticket || slot.offset !== offset) return null; + const beforeCount = slot.serverMessages.length; + const messages = withoutRepeatedIds([...older, ...slot.serverMessages]); + queryClient.setQueryData(['messages', id], { messages, total: data.total ?? slot.total, hasMore: Boolean(data.hasMore), offset: offset + older.length, tokenUsage: slot.tokenUsage }); + if (slot.status === 'loading' && slot._loadingTicket === ticket) slot.status = 'idle'; + refreshMerged(slot); + emitSession(id); + return { addedCount: messages.length - beforeCount, hasMore: slot.hasMore, total: slot.total }; } catch (error) { - console.error(`[SessionStore] fetchMore failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'idle'; emitSession(id); } return slot; + console.error(`[SessionStore] fetchMore failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'idle'; emitSession(id); } return null; } finally { slot._pendingRequests -= 1; if (slot._fetchMoreTicket === ticket) slot._fetchMoreTicket = null; if (slot._loadingTicket === ticket) slot._loadingTicket = null; evict(); } }, [emitSession, evict, queryClient, remember]); From b896f5c49919ab524067853acb0f95fc1a3e3e39 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 6 Sep 2026 22:04:35 +0900 Subject: [PATCH 2/3] fix(i18n): complete history loading retry translations Add the two history-loading error/retry keys to all eight remaining locales. PR #41 added them only to English and Korean, breaking locale parity in both Node 22 and Node 24 CI jobs. Keep the parity check intact. The focused locale and full scripts suites, lint, identity and build pass. Local full verify still encounters the previously reproduced Node oneDark import issue, unrelated to this fix. --- src/i18n/locales/de/chat.json | 2 ++ src/i18n/locales/fr/chat.json | 2 ++ src/i18n/locales/it/chat.json | 2 ++ src/i18n/locales/ja/chat.json | 2 ++ src/i18n/locales/ru/chat.json | 2 ++ src/i18n/locales/tr/chat.json | 2 ++ src/i18n/locales/zh-CN/chat.json | 2 ++ src/i18n/locales/zh-TW/chat.json | 2 ++ 8 files changed, 16 insertions(+) diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index e984108a..978a4817 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "Frühere Nachrichten abrufen…", + "olderMessagesFailed": "Frühere Nachrichten konnten nicht abgerufen werden.", + "retry": "Erneut versuchen", "sessionMessages": "Konversationsnachrichten werden abgerufen…" }, "messages": { diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index 69ed1367..66dfb548 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "Récupération des messages précédents…", + "olderMessagesFailed": "Les messages précédents n’ont pas pu être récupérés.", + "retry": "Réessayer", "sessionMessages": "Récupération des messages de conversation…" }, "messages": { diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index afb9388c..e4c82247 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "Recupero dei messaggi precedenti…", + "olderMessagesFailed": "Impossibile recuperare i messaggi precedenti.", + "retry": "Riprova", "sessionMessages": "Recupero dei messaggi di conversazione…" }, "messages": { diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 95b3f4b8..278f8da8 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "過去のメッセージを読み込んでいます...。", + "olderMessagesFailed": "過去のメッセージを読み込めませんでした。", + "retry": "再試行", "sessionMessages": "セッションメッセージを読み込んでいます...。" }, "messages": { diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index bc42905e..74030d35 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -127,6 +127,8 @@ "session": { "loading": { "olderMessages": "Получение предыдущих сообщений…", + "olderMessagesFailed": "Не удалось получить предыдущие сообщения.", + "retry": "Повторить", "sessionMessages": "Получение сообщений беседы…" }, "messages": { diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 6ec04c7c..3216d3cf 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "Önceki mesajlar alınıyor…", + "olderMessagesFailed": "Önceki mesajlar alınamadı.", + "retry": "Yeniden dene", "sessionMessages": "Konuşma mesajları alınıyor…" }, "messages": { diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 26846ed9..acc7eaef 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "正在检索早期消息……", + "olderMessagesFailed": "无法获取更早的消息。", + "retry": "重试", "sessionMessages": "正在检索对话消息……" }, "messages": { diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index ff0d1ab2..4c002a66 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -125,6 +125,8 @@ "session": { "loading": { "olderMessages": "正在取得較早的訊息…", + "olderMessagesFailed": "無法取得較早的訊息。", + "retry": "重試", "sessionMessages": "正在取得對話中的訊息…" }, "messages": { From f2f4f16a317d5a77b79e39cdfbc54a9b2feef491 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:36:29 +0900 Subject: [PATCH 3/3] fix(chat): keep history consumers working on paginated transcripts Follow-up to the visible-row pagination in this branch. - Tool-result lookup and transcript export walk the transcript in bounded pages (`fetchCompleteHistory`) instead of an unbounded read that is now refused with 413 past 5,000 visible rows. - The provider re-reads once or twice when a live writer appends between the index and payload passes; only a transcript that keeps changing on every attempt surfaces `HISTORY_CHANGED`. A removed transcript is an empty window, not a 500. - `fetchMore` distinguishes a superseded/no-op page from a failed request; the retry banner appears only for the latter or a no-progress page. - The "Get earlier / Get all messages" controls are reachable while history remains, so paginated sessions keep a click path; a refused load-all raises the same retry alert instead of vanishing. - Pointer-down stops following only on the scrollbar track, not the pane gutter, so a click beside the transcript no longer produces a stale unread badge. - The scroll anchor re-registers resize observers only when the row set changes; four unused locale keys and the orphaned `sliceTailPage` helper are removed. --- .../providers/list/gjc/GJC-PROVIDER-SPEC.md | 2 +- .../list/gjc/gjc-sessions.provider.ts | 23 +++++- .../services/session-export.service.ts | 8 +-- .../providers/services/sessions.service.ts | 47 ++++++++++-- .../providers/tests/gjc-sessions.test.ts | 71 ++++++++++++++++--- server/shared/tests/slice-tail-page.test.ts | 36 ---------- server/shared/utils.ts | 7 -- .../useChatFollowScroll.dom.bun.test.tsx | 11 ++- .../chat/hooks/useChatFollowScroll.ts | 4 +- .../useChatSessionState.dom.bun.test.tsx | 61 ++++++++++++++-- .../chat/hooks/useChatSessionState.ts | 21 +++++- src/components/chat/view/ChatMessagesPane.tsx | 4 +- src/components/chat/view/ChatScrollAnchor.tsx | 22 ++++-- .../chat/view/TurnWorkBlock.dom.bun.test.tsx | 14 +++- src/i18n/locales/de/chat.json | 4 -- src/i18n/locales/en/chat.json | 4 -- src/i18n/locales/fr/chat.json | 4 -- src/i18n/locales/it/chat.json | 5 -- src/i18n/locales/ja/chat.json | 5 -- src/i18n/locales/ko/chat.json | 4 -- src/i18n/locales/ru/chat.json | 5 -- src/i18n/locales/tr/chat.json | 4 -- src/i18n/locales/zh-CN/chat.json | 4 -- src/i18n/locales/zh-TW/chat.json | 4 -- src/stores/useSessionStore.dom.bun.test.tsx | 6 +- src/stores/useSessionStore.ts | 9 ++- 26 files changed, 252 insertions(+), 137 deletions(-) delete mode 100644 server/shared/tests/slice-tail-page.test.ts diff --git a/server/modules/providers/list/gjc/GJC-PROVIDER-SPEC.md b/server/modules/providers/list/gjc/GJC-PROVIDER-SPEC.md index 2c35d714..5363122d 100644 --- a/server/modules/providers/list/gjc/GJC-PROVIDER-SPEC.md +++ b/server/modules/providers/list/gjc/GJC-PROVIDER-SPEC.md @@ -22,7 +22,7 @@ Gajae Code App의 provider `gjc`(Gajae Code) 구현 기록. 초기 read-only 세 - `gjc-session-synchronizer.provider.ts` — 원형 `codex-session-synchronizer`. `gjcHome=~/.gjc/agent`, 스캔 `path.join(gjcHome,'sessions')`. `extractFirstValidJsonlData`로 첫 줄 파싱: **`data.id`/`data.cwd` 직접**(codex처럼 payload 아님). title=첫 user message 파생(`extractFirstUserMessageFromStart`를 gjc `type:message,role:user` content-text로 재작성) → 없으면 history.db → 없으면 `Untitled gjc Session`. 파생은 `shared/utils.ts`의 `deriveSessionTitle`(슬래시 커맨드·@멘션·코드 펜스·마크다운 제거, 첫 문장 경계, ≤40자 + `…`)을 거치며, DB에 이미 이름이 있으면 덮어쓰지 않는다. `deriveSessionTitle(filePath)`는 `POST /api/providers/sessions/:id/regenerate-title`이 사용자 요청으로 제목을 다시 파생할 때만 기존 이름을 대체한다. `sessionsDb.createSession(id, 'gjc', cwd, name, createdAt, updatedAt, filePath)`. **모델 제목**: 새 세션의 첫 턴에서 Bun 어댑터가 런타임의 `utils/title-generator`(`generateSessionTitle`, TUI와 동일 조건: 첫 user 메시지·이름 없음·`GJC_NO_TITLE` 미설정)를 호출해 `sessionManager.setSessionName(title,'auto')`로 트랜스크립트 헤더에 기록하고, `{kind:'session_title'}` 메시지를 보낸다. `ChatSessionWriter`가 이를 채팅으로 내보내지 않고 `sessionsDb.applyGeneratedSessionName`으로 저장한 뒤 `session_upserted`로 방송한다. 우선순위는 `sessions.name_source`(`user` | `auto` | `derived` | NULL=구버전 행)로 정한다: 사용자가 지은 이름은 사용자만 바꾸고, 모델 제목은 그 외 전부를 대체하며, 파생 제목은 동기화기가 값을 바꿀 때 찍힌다. - `server/modules/providers/services/gjc-session-watcher.service.ts` — `gajae-core watch`를 별도 자식 프로세스로 실행해 저장 세션 루트와 live 세션 루트 안에 canonical containment를 통과한 `.jsonl` add/change 이벤트만 64 KiB 제한 NDJSON으로 수신한다. 이벤트는 순서대로 기존 `synchronizeProviderFile('gjc', path)`에 전달하며, 큐 상한·ready 타임아웃·취소 가능한 종료 drain·지수 백오프 재시작·재시작 후 GJC 전용 reconciliation을 적용한다. GJC용 Chokidar fallback은 없고 기존 4개 provider watcher는 그대로 유지한다. - `gjc-transcript-message.ts` — 일반 메시지와 표시 가능한 사용자 스킬 요청을 공통 해석한다. 히스토리·턴 계보·제목 파생이 공유하며, 스킬의 확장 본문은 반환하지 않는다. -- `gjc-sessions.provider.ts` — `getSessionById(id).jsonl_path` → 제한된 JSONL 스트리밍 → 공통 메시지 해석 → `message.role` + `message.content[]` 파트별 user/assistant/thinking/tool_use/tool_result 정규화. timestamp 정렬 + `sliceTailPage` 페이지네이션(`createNormalizedMessage`/`generateMessageId`, 멀티 text 파트 id 충돌 방지 discriminator). +- `gjc-sessions.provider.ts` — `getSessionById(id).jsonl_path` → 제한된 JSONL 스트리밍 → 공통 메시지 해석 → `message.role` + `message.content[]` 파트별 user/assistant/thinking/tool_use/tool_result 정규화. timestamp 정렬 후 표시 행(tool_result 제외) 기준 tail 페이지네이션: 작은 인덱스 pass 뒤 선택된 행과 그 tool_result만 두 번째 pass에서 payload로 유지하고, 읽는 동안 파일이 바뀌면 재시도 후 409(`HISTORY_CHANGED`), 무제한 요청은 표시 행 5,000개까지만 허용하고 초과 시 413(`HISTORY_PAGE_TOO_LARGE`) (`createNormalizedMessage`/`generateMessageId`, 멀티 text 파트 id 충돌 방지 discriminator). - `gjc-auth.provider.ts` — `command -v gjc` + 로그인 상태(agent.db:auth_credentials 존재 or `gjc` CLI). 미설치/미인증은 데이터로 반환(예외 아님). - `gjc-skills.provider.ts` — `SkillsProvider` 확장. 루트: user `~/.gjc/agent/skills`, project `/.gjc/skills`. prefix: 스킬은 트리거 자동활성(명령형 아님) — codex `$`/claude `/` 참고해 gjc 표기 확정(잠정 `/`). - `gjc-mcp.provider.ts` — gjc MCP 설정 위치 확정 필요(미조사). 최소 안전 stub(빈 목록) 또는 조사 후. diff --git a/server/modules/providers/list/gjc/gjc-sessions.provider.ts b/server/modules/providers/list/gjc/gjc-sessions.provider.ts index 5d27800c..6ec7ab40 100644 --- a/server/modules/providers/list/gjc/gjc-sessions.provider.ts +++ b/server/modules/providers/list/gjc/gjc-sessions.provider.ts @@ -12,6 +12,7 @@ const PROVIDER = 'gjc'; const MAX_JSONL_LINE_BYTES = 32 * 1024 * 1024; const MAX_BUFFERED_HISTORY_RECORDS = 5_000; const MAX_BUFFERED_HISTORY_BYTES = 64 * 1024 * 1024; +const HISTORY_READ_ATTEMPTS = 3; type HistoryRow = { ordinal: number; time: number; kind: NormalizedMessage['kind']; toolId?: string }; /** @@ -413,9 +414,27 @@ export class GjcSessionsProvider implements IProviderSessions { const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; - if (!sessionFilePath) { - return { messages: [], total: 0, hasMore: false, offset: normalizedOffset, limit: normalizedLimit }; + const empty = { messages: [], total: 0, hasMore: false, offset: normalizedOffset, limit: normalizedLimit }; + if (!sessionFilePath) return empty; + // A live writer appends between the index and payload passes often enough + // that one changed revision is routine, not a client-visible failure. + for (let attempt = 1; ; attempt++) { + try { + return await this.readHistoryPage(sessionFilePath, sessionId, normalizedLimit, normalizedOffset); + } catch (error) { + if (error instanceof AppError && error.code === 'HISTORY_CHANGED' && attempt < HISTORY_READ_ATTEMPTS) continue; + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return empty; + throw error; + } } + } + + private async readHistoryPage( + sessionFilePath: string, + sessionId: string, + normalizedLimit: number | null, + normalizedOffset: number, + ): Promise { const revision = await fsSync.promises.stat(sessionFilePath); const turns = assignTranscriptTurns(await readTranscriptLineage(sessionFilePath)); // Index only small descriptors. Tool-result rows must not consume visible diff --git a/server/modules/providers/services/session-export.service.ts b/server/modules/providers/services/session-export.service.ts index 44ffc21f..d9ef9b3c 100644 --- a/server/modules/providers/services/session-export.service.ts +++ b/server/modules/providers/services/session-export.service.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { sessionsDb } from '@/modules/database/index.js'; -import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import { fetchCompleteHistory } from '@/modules/providers/services/sessions.service.js'; import { sessionTranscriptWorkspace } from '@/modules/providers/services/session-worktrees.service.js'; import type { LLMProvider, NormalizedMessage } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; @@ -192,10 +192,8 @@ export async function exportSessionTranscript( let messages: NormalizedMessage[] = []; const executionCwd = session.provider_session_id ? sessionTranscriptWorkspace(sessionId, projectPath) : null; if (session.provider_session_id) { - const provider = providerRegistry.resolveProvider(session.provider as LLMProvider); - const history = await provider.sessions.fetchHistory(sessionId, { - limit: null, - offset: 0, + const history = await fetchCompleteHistory(sessionId, { + provider: session.provider as LLMProvider, projectPath: executionCwd!, providerSessionId: session.provider_session_id, }); diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 2cd8a9b0..9fd951f8 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -44,6 +44,40 @@ export function prepareHistoryMessagesForTransport(messages: NormalizedMessage[] return prepareMessagesForTransport(messages, includeImages); } +export const COMPLETE_HISTORY_PAGE_SIZE = 500; + +/** + * Walks a transcript newest-first in bounded pages. Unbounded provider reads + * are rejected past the page cap, so in-server consumers that need the whole + * conversation, or one row somewhere in it, page instead. A tail that grows + * between pages only shifts later offsets forward; the rows it re-serves are + * already known and skipped, so nothing is dropped. + * + * `until` stops the walk early once the newest pages already satisfy the caller. + */ +export async function fetchCompleteHistory( + sessionId: string, + options: Omit & { providerSessionId: string; provider: LLMProvider }, + until?: (page: NormalizedMessage[]) => boolean, +): Promise { + const { provider: providerName, ...providerOptions } = options; + const provider = providerRegistry.resolveProvider(providerName); + const messages: NormalizedMessage[] = []; + const seen = new Set(); + let offset = 0; + let last: FetchHistoryResult | null = null; + for (;;) { + const page = await provider.sessions.fetchHistory(sessionId, { ...providerOptions, limit: COMPLETE_HISTORY_PAGE_SIZE, offset }); + last = page; + const fresh = page.messages.filter((message) => !seen.has(message.id)); + for (const message of fresh) seen.add(message.id); + messages.unshift(...fresh); + if (!page.hasMore || page.messages.length === 0 || until?.(fresh)) break; + offset += page.messages.length; + } + return { ...last, messages, total: last.total, hasMore: false, offset: 0, limit: null }; +} + export const sessionsService = { listProviderIds(): LLMProvider[] { return providerRegistry.listProviders().map(({ id }) => id); @@ -84,14 +118,15 @@ export const sessionsService = { async fetchToolResult(sessionId: string, toolId: string): Promise<{ toolId: string; toolResult: NonNullable; toolDetailsOmitted?: boolean }> { const row = sessionsDb.getSessionById(sessionId); if (!row?.provider_session_id) throw sessionNotFound(sessionId); - const history = await providerRegistry.resolveProvider(row.provider as LLMProvider).sessions.fetchHistory(sessionId, { - limit: null, - offset: 0, + const embeddedResult = (message: NormalizedMessage) => (message.kind === 'tool_use' && message.toolId === toolId ? message.toolResult : undefined); + const independentResult = (message: NormalizedMessage) => (message.kind === 'tool_result' && message.toolId === toolId ? message : undefined); + const history = await fetchCompleteHistory(sessionId, { + provider: row.provider as LLMProvider, projectPath: sessionTranscriptWorkspace(sessionId, row.project_path ?? ''), providerSessionId: row.provider_session_id, - }); - const embedded = history.messages.find((message) => message.kind === 'tool_use' && message.toolId === toolId && message.toolResult)?.toolResult; - const independent = history.messages.find((message) => message.kind === 'tool_result' && message.toolId === toolId); + }, (page) => page.some((message) => embeddedResult(message) || independentResult(message))); + const embedded = history.messages.map(embeddedResult).find(Boolean); + const independent = history.messages.map(independentResult).find(Boolean); const result = embedded ?? (independent ? { content: independent.content, isError: independent.isError, diff --git a/server/modules/providers/tests/gjc-sessions.test.ts b/server/modules/providers/tests/gjc-sessions.test.ts index e45e3857..efa5f367 100644 --- a/server/modules/providers/tests/gjc-sessions.test.ts +++ b/server/modules/providers/tests/gjc-sessions.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { appendFile, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -7,7 +8,8 @@ import test from 'node:test'; import { appConfigDb, closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { GjcSessionSynchronizer } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js'; import { GjcSessionsProvider } from '@/modules/providers/list/gjc/gjc-sessions.provider.js'; -import { sessionsService } from '@/modules/providers/services/sessions.service.js'; +import { exportSessionTranscript } from '@/modules/providers/services/session-export.service.js'; +import { fetchCompleteHistory, sessionsService } from '@/modules/providers/services/sessions.service.js'; const patchHomeDir = (nextHomeDir: string) => { const original = os.homedir; @@ -458,6 +460,15 @@ test('gjc sessions provider bounds page payloads without hiding older history', const lines = [ JSON.stringify({ type: 'session', version: 3, id: 'gjc-ring-history', timestamp: '2026-07-09T00:00:00.000Z', cwd: workspacePath }), ]; + // The oldest visible row is a tool call whose result only the paged walk can reach. + lines.push(JSON.stringify({ + type: 'message', id: 'oldest-call', timestamp: new Date(startTime - 2).toISOString(), + message: { role: 'assistant', content: [{ type: 'toolCall', toolName: 'read', toolInput: { path: 'a.ts' }, toolCallId: 'call-oldest' }] }, + })); + lines.push(JSON.stringify({ + type: 'message', id: 'oldest-result', timestamp: new Date(startTime - 1).toISOString(), + message: { role: 'toolResult', toolCallId: 'call-oldest', toolName: 'read', content: [{ type: 'text', text: 'oldest output' }], isError: false }, + })); for (let index = 0; index < messageCount; index += 1) { lines.push(JSON.stringify({ type: 'message', @@ -466,26 +477,43 @@ test('gjc sessions provider bounds page payloads without hiding older history', message: { role: 'user', content: [{ type: 'text', text: `message-${index}` }] }, })); } - await writeFile( - path.join(sessionsDir, '2026-07-09T00-00-00_gjc-ring-history.jsonl'), - `${lines.join('\n')}\n`, - 'utf8', - ); + const transcriptPath = path.join(sessionsDir, '2026-07-09T00-00-00_gjc-ring-history.jsonl'); + await writeFile(transcriptPath, `${lines.join('\n')}\n`, 'utf8'); await withIsolatedDatabase(async () => { await new GjcSessionSynchronizer().synchronize(); const provider = new GjcSessionsProvider(); await assert.rejects(provider.fetchHistory('gjc-ring-history'), { code: 'HISTORY_PAGE_TOO_LARGE' }); + // In-server consumers never issue the unbounded read; they page instead. + const complete = await fetchCompleteHistory('gjc-ring-history', { provider: 'gjc', providerSessionId: 'gjc-ring-history' }); + assert.equal(complete.messages.length, 5_002); + assert.equal(complete.total, 5_002); + assert.equal(complete.hasMore, false); + assert.equal(complete.messages[0]?.toolId, 'call-oldest'); + assert.equal(complete.messages[0]?.toolResult?.content, 'oldest output'); + assert.equal(complete.messages.at(-1)?.content, 'message-5000'); + assert.equal(new Set(complete.messages.map((message) => message.id)).size, 5_002, 'pages overlap only by identity'); + const oldestResult = await sessionsService.fetchToolResult('gjc-ring-history', 'call-oldest'); + assert.equal(oldestResult.toolResult.content, 'oldest output'); + await assert.rejects(sessionsService.fetchToolResult('gjc-ring-history', 'call-missing'), { code: 'TOOL_RESULT_NOT_FOUND' }); + const exported = await exportSessionTranscript('gjc-ring-history'); + assert.ok(exported.body.includes('message-0\n'), 'export reaches the oldest prompt'); + assert.ok(exported.body.includes('message-5000')); + await rm(transcriptPath); + const gone = await provider.fetchHistory('gjc-ring-history', { limit: 20 }); + assert.deepEqual({ total: gone.total, hasMore: gone.hasMore, messages: gone.messages }, { total: 0, hasMore: false, messages: [] }, 'a removed transcript is an empty window, not a 500'); + await writeFile(transcriptPath, `${lines.join('\n')}\n`, 'utf8'); const history = await provider.fetchHistory('gjc-ring-history', { limit: 5_000 }); - assert.equal(history.total, 5_001); + assert.equal(history.total, 5_002); assert.equal(history.messages.length, 5_000); assert.equal(history.messages[0]?.content, 'message-1'); assert.equal(history.messages.at(-1)?.content, 'message-5000'); assert.equal(history.hasMore, true); const oldest = await provider.fetchHistory('gjc-ring-history', { limit: 20, offset: 5_000 }); - assert.equal(oldest.messages[0]?.content, 'message-0'); - assert.equal(oldest.total, 5_001); + assert.equal(oldest.messages[0]?.toolId, 'call-oldest'); + assert.equal(oldest.messages[1]?.content, 'message-0'); + assert.equal(oldest.total, 5_002); assert.equal(oldest.hasMore, false); }); } finally { @@ -821,6 +849,31 @@ test('GJC pages count visible rows, preserve results and reach history beyond th assert.equal(oldest.hasMore, false); assert.equal(oldest.messages[0]?.content, 'oldest prompt'); await assert.rejects(provider.fetchHistory(sessionId), { code: 'HISTORY_PAGE_TOO_LARGE' }); + + // A live writer appending mid-read is absorbed by re-reading; only a + // transcript that keeps changing on every attempt surfaces as 409. + const originalStat = fs.promises.stat; + let stats = 0; + let appendsPerRead = 1; + (fs.promises as { stat: unknown }).stat = async (...args: Parameters) => { + stats += 1; + // Even calls are the post-read revision check; grow the file just before it. + if (stats % 2 === 0 && appendsPerRead > 0) { + appendsPerRead -= 1; + await appendFile(filePath, `${JSON.stringify({ type: 'message', id: `live-${stats}`, parentId: 'msg-1', + timestamp: new Date(Date.UTC(2026, 6, 11, 0, 0, stats)).toISOString(), message: { role: 'user', content: [{ type: 'text', text: `live ${stats}` }] } })}\n`, 'utf8'); + } + return originalStat(...args); + }; + try { + const settled = await provider.fetchHistory(sessionId, { limit: 20 }); + assert.equal(settled.total, 5402, 'the retry sees the row appended during the first attempt'); + assert.equal(settled.messages.at(-1)?.content, 'live 2'); + appendsPerRead = Number.POSITIVE_INFINITY; + await assert.rejects(provider.fetchHistory(sessionId, { limit: 20 }), { code: 'HISTORY_CHANGED', statusCode: 409 }); + } finally { + (fs.promises as { stat: unknown }).stat = originalStat; + } }); } finally { restoreLiveSessionDir(); diff --git a/server/shared/tests/slice-tail-page.test.ts b/server/shared/tests/slice-tail-page.test.ts deleted file mode 100644 index 1f451456..00000000 --- a/server/shared/tests/slice-tail-page.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { strict as check } from 'node:assert'; -import { test as specification } from 'node:test'; - -import { sliceTailPage as pageFromNewest } from '@/shared/utils.js'; - -const sessionTimeline = ['session-bootstrap', 'session-import', 'session-review', 'session-fix', 'session-release']; - -specification('tail pages return the requested newest window and indicate earlier records', () => { - const pageRequests = [ - { offset: 0, expected: { page: ['session-fix', 'session-release'], hasMore: true } }, - { offset: 2, expected: { page: ['session-import', 'session-review'], hasMore: true } }, - { offset: 4, expected: { page: ['session-bootstrap'], hasMore: false } }, - ]; - - for (const { offset, expected } of pageRequests) { - check.deepEqual(pageFromNewest(sessionTimeline, 2, offset), expected); - } -}); - -specification('unbounded and exhausted requests produce their complete available result', () => { - check.deepEqual(pageFromNewest(sessionTimeline, null, 0), { - page: sessionTimeline, - hasMore: false, - }); - check.deepEqual(pageFromNewest(sessionTimeline, 3, 10), { - page: [], - hasMore: false, - }); -}); - -specification('a zero-sized window still reports whether historical records remain', () => { - check.deepEqual(pageFromNewest(sessionTimeline, 0, 0), { - page: [], - hasMore: true, - }); -}); diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 3abc81f0..d48eaeaf 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -229,13 +229,6 @@ export function createCompleteMessage(opts: { provider: NormalizedMessage['provi }); } -export function sliceTailPage(items: T[], limit: number | null, offset: number): { page: T[]; hasMore: boolean } { - const endingAt = Math.max(0, items.length - Math.max(0, offset)); - if (limit === null) return { page: items.slice(0, endingAt), hasMore: false }; - const beginningAt = Math.max(0, endingAt - Math.max(0, limit)); - return { page: items.slice(beginningAt, endingAt), hasMore: beginningAt > 0 }; -} - export const readObjectRecord = (value: any): AnyRecord | null => ( value && typeof value === 'object' && !Array.isArray(value) ? value as AnyRecord : null ); diff --git a/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx index 2ac3d130..9bf5a3e5 100644 --- a/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx @@ -53,6 +53,7 @@ function Harness() { if (!node) return; Object.defineProperties(node, { clientHeight: { configurable: true, get: () => 100 }, + clientWidth: { configurable: true, get: () => 300 }, scrollHeight: { configurable: true, get: () => height }, }); }} @@ -131,13 +132,21 @@ test('a passive scroll away from the bottom does not stop following', () => { test('scrollbar interaction stops following growth', () => { const { container, grow } = setup(); - fireEvent.pointerDown(container); + // The vertical scrollbar track lies past the client box. + fireEvent.pointerDown(container, { offsetX: 305 }); container.scrollTop = 30; fireEvent.scroll(container); grow(); assert.equal(container.scrollTop, 30); }); +test('a click on the pane gutter keeps following', () => { + const { container, grow } = setup(); + fireEvent.pointerDown(container, { offsetX: 12 }); + grow(); + assertAtBottom(container); +}); + test('PageUp from a transcript child stops following', () => { const { container, content, grow } = setup(); container.scrollTop = 30; diff --git a/src/components/chat/hooks/useChatFollowScroll.ts b/src/components/chat/hooks/useChatFollowScroll.ts index e398e34b..9279d81a 100644 --- a/src/components/chat/hooks/useChatFollowScroll.ts +++ b/src/components/chat/hooks/useChatFollowScroll.ts @@ -51,7 +51,9 @@ export function useChatFollowScroll({ scrollContainerRef, enabled }: UseChatFoll if (touchStartY !== null && touchY !== undefined && touchY > touchStartY) stopFollowing(); }; const onPointerDown = (event: PointerEvent) => { - if (event.target === node) stopFollowing(); + // Only a grab of the vertical scrollbar track (past the client box) is a + // scroll intent; the pane's own gutter and padding also report the node. + if (event.target === node && event.offsetX >= node.clientWidth) stopFollowing(); }; const onKeyDown = (event: KeyboardEvent) => { const target = event.target; diff --git a/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx b/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx index 92769a73..ff763e05 100644 --- a/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx @@ -5,7 +5,7 @@ import { act, cleanup, render, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createElement } from 'react'; -import { useSessionStore, type NormalizedMessage, type SessionSlot, type SessionStore } from '../../../stores/useSessionStore'; +import { useSessionStore, type FetchMoreResult, type NormalizedMessage, type SessionSlot, type SessionStore } from '../../../stores/useSessionStore'; import type { Project, ProjectSession } from '../../../types/app'; import { useChatSessionState } from './useChatSessionState'; @@ -169,7 +169,7 @@ async function setup() { globalThis.fetch = (async () => new Response('{}', { status: 200 })) as typeof fetch; let rows = [message('first', 20), message('latest', 30)]; let pageRequests = 0; - let pageResolve: ((value: { addedCount: number; hasMore: boolean; total: number }) => void) | undefined; + let pageResolve: ((value: FetchMoreResult) => void) | undefined; let allResolve: ((value: unknown) => void) | undefined; let state: ReturnType | undefined; const store = { @@ -212,23 +212,29 @@ async function setup() { await act(async () => { const addedCount = next.length - rows.length; rows = next; - pageResolve!({ addedCount, hasMore, total: next.length }); + pageResolve!({ failed: false, addedCount, hasMore, total: next.length }); await request!; }); }, + async pageOutcome(outcome: { failed: true } | null) { + let request: Promise; + act(() => { request = state!.handleScroll(); }); + assert.ok(pageResolve); + await act(async () => { pageResolve!(outcome); await request!; }); + }, async retry(next: NormalizedMessage[]) { act(() => state!.retryOlderMessages()); const addedCount = next.length - rows.length; rows = next; - await act(async () => { pageResolve!({ addedCount, hasMore: false, total: next.length }); }); + await act(async () => { pageResolve!({ failed: false, addedCount, hasMore: false, total: next.length }); }); }, - async all(next: NormalizedMessage[]) { + async all(next: NormalizedMessage[] | null) { let request: Promise; act(() => { request = state!.loadAllMessages(); }); assert.ok(allResolve); await act(async () => { - rows = next; - allResolve!({ serverMessages: rows, hasMore: false, total: rows.length }); + if (next) rows = next; + allResolve!(next ? { serverMessages: rows, hasMore: false, total: rows.length } : null); await request!; }); }, @@ -259,6 +265,47 @@ test('an empty page claiming more history stops automatic retries without hiding } finally { harness.close(); } }); +test('a superseded page stays quiet while a failed request raises the retry banner', async () => { + const harness = await setup(); + try { + // A reconcile after a live turn discards the in-flight page: not an error. + await harness.pageOutcome(null); + assert.equal(harness.state().historyLoadError, false); + assert.equal(harness.state().hasMoreMessages, true); + await harness.pageOutcome({ failed: true }); + assert.equal(harness.state().historyLoadError, true); + assert.equal(harness.pageRequests(), 2); + await act(async () => { await harness.state().handleScroll(); }); + assert.equal(harness.pageRequests(), 2, 'a failed page is not retried automatically'); + await harness.retry([message('recovered', 0), ...harness.rows()]); + assert.equal(harness.state().historyLoadError, false); + } finally { harness.close(); } +}); + +test('a refused load-all surfaces the retry banner and the paged path still works', async () => { + const harness = await setup(); + try { + await harness.all(null); + assert.equal(harness.state().historyLoadError, true); + assert.equal(harness.state().allMessagesLoaded, false); + assert.equal(harness.state().isLoadingAllMessages, false); + await act(async () => { await harness.state().handleScroll(); }); + assert.equal(harness.pageRequests(), 0, 'no automatic paging behind an unresolved error'); + await harness.retry([message('recovered', 0), ...harness.rows()]); + assert.equal(harness.state().historyLoadError, false); + assert.equal(harness.pageRequests(), 1); + } finally { harness.close(); } +}); + +test('load earlier pages further back once the loaded window is fully visible', async () => { + const harness = await setup(); + try { + assert.equal(harness.state().hasMoreMessages, true); + act(() => harness.state().loadEarlierMessages()); + assert.equal(harness.pageRequests(), 1, 'nothing hidden locally, so the control fetches a page'); + } finally { harness.close(); } +}); + test('reaching the top loads immediately without another leave-and-return gesture', async () => { const harness = await setup(); try { diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index e709db3a..bd5252dd 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -273,7 +273,10 @@ export function useChatSessionState({ try { const page = await sessionStore.fetchMore(requestId, { limit: PAGE_SIZE, includeImages: showImagePreviews }); if (requestViewRef.current !== requestView) return false; - if (!page || (page.addedCount === 0 && page.hasMore)) { + // A superseded or no-op page is not a failure; a reconcile after a live + // turn may discard an in-flight page and the next scroll asks again. + if (!page) return false; + if (page.failed || (page.addedCount === 0 && page.hasMore)) { // Never spin at one offset or pretend unreachable history is complete. historyLoadErrorRef.current = true; setHistoryLoadError(true); @@ -555,8 +558,12 @@ export function useChatSessionState({ const window = await sessionStore.fetchFromServer(requestId, { limit: null, offset: 0, includeImages: showImagePreviews }); if (requestViewRef.current !== requestView) return; if (!window) { + // Too large to serve at once, or otherwise refused: say so and offer + // the paged path instead of quietly dropping the request. loadedAllRef.current = false; setShowLoadAllOverlay(false); + historyLoadErrorRef.current = true; + setHistoryLoadError(true); return; } setHasMoreMessages(false); @@ -576,6 +583,8 @@ export function useChatSessionState({ console.error('Error loading all messages:', error); loadedAllRef.current = false; setShowLoadAllOverlay(false); + historyLoadErrorRef.current = true; + setHistoryLoadError(true); } finally { if (requestViewRef.current === requestView) { loadingMoreRef.current = false; @@ -583,7 +592,15 @@ export function useChatSessionState({ } } }, [isLoadingAllMessages, selectedProject, selectedSession, sessionStore, setFollowing, showImagePreviews]); - const loadEarlierMessages = useCallback(() => setVisibleMessageCount(count => count + 100), []); + // Reveals rows already loaded first; once the loaded window is fully shown, + // the same control pages further back so every history state has a click path. + const loadEarlierMessages = useCallback(() => { + if (visibleMessageCount < chatMessages.length) { + setVisibleMessageCount(count => count + 100); + return; + } + void loadOlderMessages(); + }, [chatMessages.length, loadOlderMessages, visibleMessageCount]); return { chatMessages, diff --git a/src/components/chat/view/ChatMessagesPane.tsx b/src/components/chat/view/ChatMessagesPane.tsx index fcc16153..6fcc3c89 100644 --- a/src/components/chat/view/ChatMessagesPane.tsx +++ b/src/components/chat/view/ChatMessagesPane.tsx @@ -165,8 +165,8 @@ function ChatMessagesPane({ /> ) : ( <> - {!hasMoreMessages && chatMessages.length > visibleMessageCount ? ( -
+ {(hasMoreMessages || chatMessages.length > visibleMessageCount) && !isLoadingAllMessages && !historyLoadError ? ( +
diff --git a/src/components/chat/view/ChatScrollAnchor.tsx b/src/components/chat/view/ChatScrollAnchor.tsx index 061fbd21..1285c54d 100644 --- a/src/components/chat/view/ChatScrollAnchor.tsx +++ b/src/components/chat/view/ChatScrollAnchor.tsx @@ -17,6 +17,7 @@ export default class ChatScrollAnchor extends Component(); componentDidMount() { this.connect(); @@ -36,7 +37,6 @@ export default class ChatScrollAnchor extends Component, snapshot: Anchor | null) { this.connect(); - if (previous.sessionKey !== this.props.sessionKey || !this.props.enabled) this.anchor = null; this.anchor = snapshot ? this.restore(snapshot) : this.props.enabled ? this.capture() : null; } @@ -48,6 +48,7 @@ export default class ChatScrollAnchor extends Component([this.node]); + if (this.node.firstElementChild) targets.add(this.node.firstElementChild); + for (const row of this.node.querySelectorAll('[data-scroll-anchor]')) targets.add(row); + if (targets.size === this.observed.size && [...targets].every((target) => this.observed.has(target))) return; + this.observer.disconnect(); + for (const target of targets) this.observer.observe(target); + this.observed = targets; } private capture(): Anchor | null { diff --git a/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx b/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx index 69c837f7..4d7f5156 100644 --- a/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx +++ b/src/components/chat/view/TurnWorkBlock.dom.bun.test.tsx @@ -261,8 +261,14 @@ test('history shows only an active loading indicator, without an idle count barr const anchor = scrollPane.querySelector('[data-scroll-anchor]'); assert.ok(anchor); assert.equal(view.container.querySelector('[data-pagination-status]'), null); - assert.ok(!scrollPane.textContent?.includes(enChat.session.messages.scrollToLoad)); assert.equal(view.container.querySelector('[data-load-all-overlay]'), null); + // Paginated history keeps a click path to older rows and to the whole conversation. + const controls = scrollPane.querySelector('[data-history-controls]') as HTMLElement; + assert.ok(controls); + fireEvent.click(within(controls).getByRole('button', { name: enChat.session.messages.loadEarlier })); + fireEvent.click(within(controls).getByRole('button', { name: enChat.session.messages.loadAll })); + assert.equal(loadEarlierCalls, 1); + assert.equal(loadAllCalls, 1); view.rerender(createElement(ChatMessagesPane, { ...props, isLoadingMoreMessages: true })); const status = view.container.querySelector('[data-pagination-status]'); @@ -279,6 +285,7 @@ test('history shows only an active loading indicator, without an idle count barr view.rerender(createElement(ChatMessagesPane, { ...props, isLoadingAllMessages: true })); const overlay = view.container.querySelector('[data-load-all-overlay]')!; assert.ok(overlay); + assert.equal(scrollPane.querySelector('[data-history-controls]'), null, 'no second entry point while loading everything'); assert.equal(scrollPane.contains(overlay), false); const loadingButton = within(overlay as HTMLElement).getByRole('button') as HTMLButtonElement; assert.equal(loadingButton.disabled, true); @@ -286,14 +293,15 @@ test('history shows only an active loading indicator, without an idle count barr view.rerender(createElement(ChatMessagesPane, { ...props, hasMoreMessages: false, allMessagesLoaded: true })); assert.equal(view.container.querySelector('[data-load-all-overlay]'), null); + assert.equal(scrollPane.querySelector('[data-history-controls]'), null); assert.equal(view.container.querySelector('[data-pagination-status]'), null); assert.equal(scrollPane.querySelector('[data-scroll-anchor]'), anchor); view.rerender(createElement(ChatMessagesPane, { ...props, hasMoreMessages: false, visibleMessageCount: 2 })); fireEvent.click(within(scrollPane as HTMLElement).getByRole('button', { name: enChat.session.messages.loadEarlier })); fireEvent.click(within(scrollPane as HTMLElement).getByRole('button', { name: enChat.session.messages.loadAll })); - assert.equal(loadEarlierCalls, 1); - assert.equal(loadAllCalls, 1); + assert.equal(loadEarlierCalls, 2); + assert.equal(loadAllCalls, 2); }); test('a history failure stays visible with an explicit retry instead of flashing a spinner', () => { diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index 5b87fd14..3c24d23c 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "Konversationsnachrichten werden abgerufen…" }, "messages": { - "showingOf": "Anzeige von {{shown}} von {{total}}-Nachrichten", - "loadedCount": "Geladene Nachrichten: {{shown}}", - "scrollToLoad": "Scrollen Sie nach oben, um mehr zu erfahren", - "showingLast": "Anzeige der neuesten {{count}}-Meldungen von {{total}}", "loadEarlier": "Erhalten Sie frühere Nachrichten", "loadAll": "Erhalten Sie alle Nachrichten", "loadFullToolOutput": "Erhalten Sie eine vollständige Ausgabe", diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 667d9d69..9dd2889b 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "Retrieving conversation messages…" }, "messages": { - "showingOf": "Displaying {{shown}} of {{total}} messages", - "loadedCount": "Loaded messages: {{shown}}", - "scrollToLoad": "Scroll upward for more", - "showingLast": "Displaying the latest {{count}} messages of {{total}}", "loadEarlier": "Get earlier messages", "loadAll": "Get all messages", "loadFullToolOutput": "Get complete output", diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index dc33c87a..e4206344 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "Récupération des messages de conversation…" }, "messages": { - "showingOf": "Affichage des messages {{shown}} sur {{total}}", - "loadedCount": "Messages chargés : {{shown}}", - "scrollToLoad": "Faites défiler vers le haut pour en savoir plus", - "showingLast": "Affichage des derniers messages {{count}} de {{total}}", "loadEarlier": "Recevez des messages plus tôt", "loadAll": "Recevez tous les messages", "loadFullToolOutput": "Obtenez une sortie complète", diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index cc4e8127..060a094e 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -64,7 +64,6 @@ "stopSpeaking": "Fermati", "loading": "Caricamento…" }, - "input": { "placeholder": "Chiedi {{provider}}, usa / per i comandi o @ per aggiungere file...", "modelReasoning": { @@ -134,10 +133,6 @@ "sessionMessages": "Recupero dei messaggi di conversazione…" }, "messages": { - "showingOf": "Visualizzazione di {{shown}} di messaggi {{total}}", - "loadedCount": "Messaggi caricati: {{shown}}", - "scrollToLoad": "Scorri verso l'alto per ulteriori informazioni", - "showingLast": "Visualizzazione degli ultimi messaggi {{count}} di {{total}}", "loadEarlier": "Ricevi messaggi precedenti", "loadAll": "Ricevi tutti i messaggi", "loadFullToolOutput": "Ottieni l'output completo", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index e2909c2c..2dc6ee3e 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -64,7 +64,6 @@ "stopSpeaking": "停止。", "loading": "読み込み中…" }, - "input": { "placeholder": "/ でコマンド、@ でファイル指定、または {{provider}} に何でも聞いてください...。", "modelReasoning": { @@ -134,10 +133,6 @@ "sessionMessages": "セッションメッセージを読み込んでいます...。" }, "messages": { - "showingOf": "{{total}}件中{{shown}}件を表示。", - "loadedCount": "読み込み済みメッセージ: {{shown}}件", - "scrollToLoad": "上にスクロールしてさらに読み込む。", - "showingLast": "最新{{count}}件を表示(全{{total}}件)。", "loadEarlier": "過去のメッセージを読み込む。", "loadAll": "すべてのメッセージを取得。", "loadFullToolOutput": "完全な出力を読み込む。", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index d1fb4e83..9568f832 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "대화 메시지 로딩 중..." }, "messages": { - "showingOf": "{{shown}}개 중 {{total}}개의 메시지를 표시", - "loadedCount": "불러온 메시지: {{shown}}개", - "scrollToLoad": "더 많은 내용을 위해 위로 스크롤하세요", - "showingLast": "{{count}}개 중 {{total}}개의 최신 메시지를 표시", "loadEarlier": "이전 메시지 가져오기", "loadAll": "모든 메시지 가져오기", "loadFullToolOutput": "완전한 출력 가져오기", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index f1ea5655..323d3efa 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -64,7 +64,6 @@ "stopSpeaking": "Останавливаться", "loading": "Загрузка…" }, - "input": { "placeholder": "Спросите {{provider}}, используйте / для команд или @ для добавления файлов…", "modelReasoning": { @@ -136,10 +135,6 @@ "sessionMessages": "Получение сообщений беседы…" }, "messages": { - "showingOf": "Отображение {{shown}} из {{total}} сообщений", - "loadedCount": "Загружено сообщений: {{shown}}", - "scrollToLoad": "Прокрутите вверх, чтобы увидеть больше", - "showingLast": "Отображение последних сообщений {{count}} из {{total}}", "loadEarlier": "Получить более ранние сообщения", "loadAll": "Получить все сообщения", "loadFullToolOutput": "Получить полный вывод", diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index f8258876..9be3a906 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "Konuşma mesajları alınıyor…" }, "messages": { - "showingOf": " iletiden tanesi görüntüleniyor {{shown}} {{total}}", - "loadedCount": "Yüklenen iletiler: {{shown}}", - "scrollToLoad": "Daha fazlası için yukarı kaydırın", - "showingLast": " sayısının en son mesajı görüntüleniyor {{count}} {{total}}", "loadEarlier": "Daha erken mesajlar alın", "loadAll": "Tüm mesajları al", "loadFullToolOutput": "Tam çıktı alın", diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 2ee64555..47dc1928 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "正在检索对话消息……" }, "messages": { - "showingOf": "正在显示 {{shown}} / {{total}} 条消息", - "loadedCount": "已加载消息:{{shown}} 条", - "scrollToLoad": "向上滚动以查看更多", - "showingLast": "正在显示最新的{{count}}条消息,共{{total}}条", "loadEarlier": "获取更早的消息", "loadAll": "获取所有消息", "loadFullToolOutput": "获取完整输出", diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 4624b124..4827fb7c 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -133,10 +133,6 @@ "sessionMessages": "正在取得對話中的訊息…" }, "messages": { - "showingOf": "顯示 {{shown}} 共計 {{total}} 筆訊息", - "loadedCount": "已載入訊息:{{shown}} 筆", - "scrollToLoad": "向上捲動以查看更多", - "showingLast": "顯示最新 {{count}} 筆共計 {{total}} 筆訊息", "loadEarlier": "取得較早的訊息", "loadAll": "取得所有訊息", "loadFullToolOutput": "取得完整的輸出", diff --git a/src/stores/useSessionStore.dom.bun.test.tsx b/src/stores/useSessionStore.dom.bun.test.tsx index 13503514..8a13ffa1 100644 --- a/src/stores/useSessionStore.dom.bun.test.tsx +++ b/src/stores/useSessionStore.dom.bun.test.tsx @@ -224,7 +224,7 @@ test('fetchMore serializes a captured offset and deduplicates only matching mess hasMore: false, })); const [accepted, duplicate] = await Promise.all([firstPage, duplicatePage]); - assert.deepEqual(accepted, { addedCount: 1, hasMore: false, total: 4 }); + assert.deepEqual(accepted, { failed: false, addedCount: 1, hasMore: false, total: 4 }); assert.equal(duplicate, null); const slot = store.getSessionSlot('session')!; @@ -247,7 +247,7 @@ test('failed, empty and superseded older pages never report an insertion', async await initial; const failed = store.fetchMore('session'); pending.shift()!.resolve(new Response('', { status: 500 })); - assert.equal(await failed, null); + assert.deepEqual(await failed, { failed: true }, 'a failed request is distinguishable from a superseded one'); assert.equal(store.getSessionSlot('session')!.offset, 1); const obsolete = store.fetchMore('session'); @@ -260,7 +260,7 @@ test('failed, empty and superseded older pages never report an insertion', async const empty = store.fetchMore('session'); pending.shift()!.resolve(response({ messages: [], total: 1, hasMore: false })); - assert.deepEqual(await empty, { addedCount: 0, hasMore: false, total: 1 }); + assert.deepEqual(await empty, { failed: false, addedCount: 0, hasMore: false, total: 1 }); assert.deepEqual(store.getMessages('session').map(row => row.id), ['saved']); } finally { globalThis.fetch = originalFetch; diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index a6a5f667..85c35213 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -28,6 +28,7 @@ export interface SessionSlot { total: number; hasMore: boolean; offset: number; tokenUsage: unknown; } export type MessagesWindow = { messages: NormalizedMessage[]; total: number; hasMore: boolean; offset: number; tokenUsage?: unknown }; +export type FetchMoreResult = { failed: false; addedCount: number; hasMore: boolean; total: number } | { failed: true } | null; export type JobProjectionSlot = { snapshot: JobSnapshot | null; lastAppliedSequence: number; eventsBySequence: Map; orderedTail: JobProjectionEvent[]; status: 'idle' | 'subscribed' | 'error'; error: JobProjectionErrorCode | 'protocol_violation' | null }; const EMPTY: NormalizedMessage[] = []; @@ -344,7 +345,9 @@ export function useSessionStore() { } finally { slot._pendingRequests -= 1; if (slot._loadingTicket === ticket) slot._loadingTicket = null; evict(); } }, [begin, emitSession, evict, queryClient]); - const fetchMore = useCallback(async (id: string, options: { limit?: number; includeImages?: boolean } = {}) => { + // `null` means nothing happened (no older rows, a page already in flight, or + // a newer fetch superseded this one); only a failed request reports `failed`. + const fetchMore = useCallback(async (id: string, options: { limit?: number; includeImages?: boolean } = {}): Promise => { const slot = slots.current.get(id) ?? newSlot(id, queryClient); if (typeof options.includeImages === 'boolean') slot._includeImages = options.includeImages; if (!slot.hasMore || slot._fetchMoreTicket !== null) { remember(id, slot); return null; } const offset = slot.offset; const ticket = ++slot._fetchSeq; slot._fetchMoreTicket = ticket; slot._pendingRequests += 1; remember(id, slot); if (slot.status === 'loading') slot._loadingTicket = ticket; @@ -359,9 +362,9 @@ export function useSessionStore() { if (slot.status === 'loading' && slot._loadingTicket === ticket) slot.status = 'idle'; refreshMerged(slot); emitSession(id); - return { addedCount: messages.length - beforeCount, hasMore: slot.hasMore, total: slot.total }; + return { failed: false, addedCount: messages.length - beforeCount, hasMore: slot.hasMore, total: slot.total }; } catch (error) { - console.error(`[SessionStore] fetchMore failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'idle'; emitSession(id); } return null; + console.error(`[SessionStore] fetchMore failed for ${id}:`, error); if (ticket === slot._fetchSeq && slot.status === 'loading' && slot._loadingTicket === ticket) { slot.status = 'idle'; emitSession(id); } return { failed: true }; } finally { slot._pendingRequests -= 1; if (slot._fetchMoreTicket === ticket) slot._fetchMoreTicket = null; if (slot._loadingTicket === ticket) slot._loadingTicket = null; evict(); } }, [emitSession, evict, queryClient, remember]);