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 486e30e9..6ec7ab40 100644 --- a/server/modules/providers/list/gjc/gjc-sessions.provider.ts +++ b/server/modules/providers/list/gjc/gjc-sessions.provider.ts @@ -5,90 +5,15 @@ 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), - ); -} +const HISTORY_READ_ATTEMPTS = 3; +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 +122,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 +270,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 +413,92 @@ 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; + 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; + } } + } - 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); + 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 + // 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/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 82baa86d..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; @@ -443,7 +445,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'); @@ -458,6 +460,15 @@ test('gjc sessions provider keeps only the bounded normalized history tail', { c 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,22 +477,44 @@ test('gjc sessions provider keeps only the bounded normalized history tail', { c 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 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' }); + // 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_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]?.toolId, 'call-oldest'); + assert.equal(oldest.messages[1]?.content, 'message-0'); + assert.equal(oldest.total, 5_002); + assert.equal(oldest.hasMore, false); }); } finally { restoreLiveSessionDir(); @@ -760,6 +793,95 @@ 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' }); + + // 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(); + 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/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 765f02c1..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 }, }); }} @@ -129,6 +130,31 @@ test('a passive scroll away from the bottom does not stop following', () => { assertAtBottom(container); }); +test('scrollbar interaction stops following growth', () => { + const { container, grow } = setup(); + // 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; + 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..9279d81a 100644 --- a/src/components/chat/hooks/useChatFollowScroll.ts +++ b/src/components/chat/hooks/useChatFollowScroll.ts @@ -50,20 +50,29 @@ export function useChatFollowScroll({ scrollContainerRef, enabled }: UseChatFoll const touchY = event.touches[0]?.clientY; if (touchStartY !== null && touchY !== undefined && touchY > touchStartY) stopFollowing(); }; + const onPointerDown = (event: PointerEvent) => { + // 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) => { - 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 729d61e4..cde9b41f 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, @@ -116,7 +117,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..ff763e05 100644 --- a/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatSessionState.dom.bun.test.tsx @@ -1,11 +1,11 @@ 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'; -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'; @@ -158,3 +158,283 @@ 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: FetchMoreResult) => 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!({ 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!({ failed: false, addedCount, hasMore: false, total: next.length }); }); + }, + async all(next: NormalizedMessage[] | null) { + let request: Promise; + act(() => { request = state!.loadAllMessages(); }); + assert.ok(allResolve); + await act(async () => { + if (next) rows = next; + allResolve!(next ? { serverMessages: rows, hasMore: false, total: rows.length } : null); + 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('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 { + 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..bd5252dd 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,78 @@ 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; + // 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.serverMessages.length) { - if (!page.hasMore) { - loadedAllRef.current = true; - setAllMessagesLoaded(true); - setHasMoreMessages(false); - clearTimer(overlayTimerRef); - setShowLoadAllOverlay(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); 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 +359,7 @@ export function useChatSessionState({ }; frameId = requestAnimationFrame(settle); return () => cancelAnimationFrame(frameId); - }, [chatMessages.length, isLoadingSessionMessages]); + }, [chatMessages.length, isFollowing, isLoadingSessionMessages]); useEffect(() => { if (!selectedProject || !selectedSession) { @@ -525,19 +526,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,18 +553,19 @@ 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; 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; } - if (previous) restoreScrollRef.current = previous; setHasMoreMessages(false); setTotalMessages(window.total); ignoredOffsetRef.current = window.total; @@ -582,14 +583,24 @@ 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; setIsLoadingAllMessages(false); } } - }, [isLoadingAllMessages, selectedProject, selectedSession, sessionStore, showImagePreviews]); - const loadEarlierMessages = useCallback(() => setVisibleMessageCount(count => count + 100), []); + }, [isLoadingAllMessages, selectedProject, selectedSession, sessionStore, setFollowing, showImagePreviews]); + // 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, @@ -603,6 +614,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 bfde1abe..6b09a4e6 100644 --- a/src/components/chat/tests/chatMessagesPaneCount.test.tsx +++ b/src/components/chat/tests/chatMessagesPaneCount.test.tsx @@ -20,7 +20,7 @@ function renderCount(loaded: number, persistedTotal: number, extra: Partial [], selectedProject: { projectId: 'project', fullPath: '/project', displayName: 'Project' }, @@ -28,29 +28,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', () => { @@ -63,7 +57,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 9f77e304..e053874b 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'; import { buildPaneList, isTurnWorkBlockItem } from '../utils/turnWork'; /* @@ -40,6 +41,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]); @@ -61,6 +64,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', () => { @@ -70,9 +104,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 4bec6514..45723a39 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -340,6 +340,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) && !isLoadingAllMessages && !historyLoadError ? ( +
+ + {' | '} + +
+ ) : 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..1285c54d --- /dev/null +++ b/src/components/chat/view/ChatScrollAnchor.tsx @@ -0,0 +1,152 @@ +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 = ''; + private observed = new Set(); + + 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(); + 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; + this.observed.clear(); + 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() { + if (!this.node || !this.observer || !this.props.enabled) { + this.observer?.disconnect(); + this.observed.clear(); + return; + } + // Every commit passes through here; only a changed row set re-registers, + // since each fresh observe also schedules an initial notification. + const targets = new Set([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 { + 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..4d7f5156 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,120 @@ 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.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]'); + 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.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); + 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(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, 2); + assert.equal(loadAllCalls, 2); +}); + +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/de/chat.json b/src/i18n/locales/de/chat.json index 0b98d219..3c24d23c 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "Frühere Nachrichten abrufen…", + "olderMessagesFailed": "Frühere Nachrichten konnten nicht abgerufen werden.", + "retry": "Erneut versuchen", "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 e6a44342..9dd2889b 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "Retrieving earlier messages…", + "olderMessagesFailed": "Earlier messages could not be retrieved.", + "retry": "Retry", "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 9c9a0a45..e4206344 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -128,13 +128,11 @@ "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": { - "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 82ad3f97..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": { @@ -129,13 +128,11 @@ "session": { "loading": { "olderMessages": "Recupero dei messaggi precedenti…", + "olderMessagesFailed": "Impossibile recuperare i messaggi precedenti.", + "retry": "Riprova", "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 04044d51..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": { @@ -129,13 +128,11 @@ "session": { "loading": { "olderMessages": "過去のメッセージを読み込んでいます...。", + "olderMessagesFailed": "過去のメッセージを読み込めませんでした。", + "retry": "再試行", "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 2851d574..9568f832 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "이전 메시지 로딩 중...", + "olderMessagesFailed": "이전 메시지를 불러오지 못했습니다.", + "retry": "다시 시도", "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 2090aa7a..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": { @@ -131,13 +130,11 @@ "session": { "loading": { "olderMessages": "Получение предыдущих сообщений…", + "olderMessagesFailed": "Не удалось получить предыдущие сообщения.", + "retry": "Повторить", "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 f32ab77b..9be3a906 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "Önceki mesajlar alınıyor…", + "olderMessagesFailed": "Önceki mesajlar alınamadı.", + "retry": "Yeniden dene", "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 94cb161c..47dc1928 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "正在检索早期消息……", + "olderMessagesFailed": "无法获取更早的消息。", + "retry": "重试", "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 129f9ac4..4827fb7c 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -128,13 +128,11 @@ "session": { "loading": { "olderMessages": "正在取得較早的訊息…", + "olderMessagesFailed": "無法取得較早的訊息。", + "retry": "重試", "sessionMessages": "正在取得對話中的訊息…" }, "messages": { - "showingOf": "顯示 {{shown}} 共計 {{total}} 筆訊息", - "loadedCount": "已載入訊息:{{shown}} 筆", - "scrollToLoad": "向上捲動以查看更多", - "showingLast": "顯示最新 {{count}} 筆共計 {{total}} 筆訊息", "loadEarlier": "取得較早的訊息", "loadAll": "取得所有訊息", "loadFullToolOutput": "取得完整的輸出", 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..8a13ffa1 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, { failed: false, 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.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'); + 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, { failed: false, 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..85c35213 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 { @@ -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[] = []; @@ -232,6 +233,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 +337,34 @@ 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 } = {}) => { + // `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 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 { 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 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 { 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]);