diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 308b9dce42..fc90412295 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -25,7 +25,10 @@ import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; -import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, +} from '../../preload/transcript-contract.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, @@ -297,6 +300,100 @@ test('loads a history target with newer messages available below it', async () = assert.equal(replica.snapshot().hasNewer, true); }); +test('keeps an oversized transcript sparse while moving between indexed prompts', async () => { + const messages = syntheticLargeTranscript(); + const totalBytes = messages.reduce( + (total, entry) => total + Buffer.byteLength(JSON.stringify(entry.message), 'utf8'), + 0, + ); + assert.ok(totalBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES * 2); + + const bootstrapPage = transcriptPage('older', 'older', 15); + const historicalPage = transcriptPage('newer', 'newer', 15); + const intermediatePage = transcriptPage('newer', 'newer', 15); + const latestPage = transcriptPage('older', 'older', 15); + const requests: Array<{ + direction: 'older' | 'newer'; + anchorSequence: number | null; + maxBytes: number; + }> = []; + const rendererStore = transcriptStore(); + const generation = 'oversized-range'; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 15, + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 15), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage + ? { messages: messages.slice(12, 16), nextCursor: 'older' } + : page === historicalPage + ? { messages: messages.slice(0, 5), nextCursor: 'newer' } + : { messages: messages.slice(6, 11), nextCursor: 'newer' }, + loadTranscriptPage: async (input) => { + requests.push({ + direction: input.direction, + anchorSequence: input.anchorSequence, + maxBytes: input.maxBytes, + }); + if (input.direction === 'older') return latestPage; + return input.anchorSequence === null ? historicalPage : intermediatePage; + }, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + generation, + onChange: (_current, change) => { + for (const batch of encodeDesktopTranscriptChange({ + sessionId: 'session-1', + generation, + hostEpoch: 'host-1', + }, change)) rendererStore.accept(batch); + }, + }); + for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) { + rendererStore.accept(batch); + } + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); + assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); + assert.equal(rendererStore.range().hasNewer, false); + assertRangeFitsBudget(rendererStore); + + await replica.loadAround(0, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4]); + assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 1', 'Prompt 2', 'Prompt 3']); + assert.equal(rendererStore.range().hasOlder, false); + assert.equal(rendererStore.range().hasNewer, true); + assertRangeFitsBudget(rendererStore); + + await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]); + assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']); + assert.equal(rendererStore.range().hasOlder, true); + assert.equal(rendererStore.range().hasNewer, true); + assertRangeFitsBudget(rendererStore); + + await replica.loadAround(15, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); + assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); + assert.equal(rendererStore.range().hasOlder, true); + assert.equal(rendererStore.range().hasNewer, false); + assertRangeFitsBudget(rendererStore); + + assert.deepEqual(requests, [ + { direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, + { direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, + { direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, + ]); + replica.close(); +}); + test('rejects an overlay that exceeds its cache budget', async () => { const messages = [ assistantMessage('x'.repeat(700), 'overlay-1'), @@ -562,6 +659,43 @@ function transcriptPage( }; } +function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> { + return Array.from({ length: 8 }, (_, index) => { + const number = index + 1; + const turnId = `turn-${number}`; + return [ + { + identity: index * 2, + message: { + ...userMessage(`Prompt ${number}`, `user-${number}`), + turnId, + }, + }, + { + identity: index * 2 + 1, + message: { + ...assistantMessage('x'.repeat(180 * 1024), `assistant-${number}`), + turnId, + }, + }, + ]; + }).flat(); +} + +function renderedUserPrompts(store: DesktopTranscriptRangeStore): string[] { + return store.snapshot().messages.flatMap((message) => + message.type === 'user' ? [message.text] : [], + ); +} + +function assertRangeFitsBudget(store: DesktopTranscriptRangeStore): void { + const bytes = store.snapshot().messages.reduce( + (total, message) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), + 0, + ); + assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); +} + function continuitySnapshot() { return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 6ce3ac5611..8b196dc1a8 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -250,6 +250,8 @@ export function ChatMessageSurface({ historyLoadPending={historyLoadPending} onLoadEarlierHistory={onLoadEarlierHistory} returnToLatest={hasNewerHistory ? { + title: transcriptCopy.partialHistoryTitle, + description: transcriptCopy.partialHistoryDescription, label: transcriptCopy.returnLatest, isPending: historyLoadPending, onClick: onReturnToLatestHistory, diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 52496bd793..8f7cdf0ba1 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -56,6 +56,8 @@ export interface DesktopConversationCopy { modelReboundTitle: string; modelReboundDescription: (modelId?: string) => string; messageReadFailedTitle: string; + partialHistoryTitle: string; + partialHistoryDescription: string; returnLatest: string; scrollMainToBottom: string; }; @@ -416,7 +418,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { zh: { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', partialHistoryTitle: '正在查看较早的一段对话', partialHistoryDescription: '较新的消息仍已保存,但当前未加载。', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' }, model: { fakeBackendLabel: '本地模拟连接', @@ -641,7 +643,7 @@ const COPY = { turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing an earlier part of this conversation', partialHistoryDescription: 'Newer messages are still saved, but are not currently loaded.', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' }, model: { fakeBackendLabel: 'Local simulation', diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 17d2e8f7a9..12f14e623a 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -319,7 +319,32 @@ } .maka-transcript-history-controls { display: flex; - justify-content: center; - gap: 8px; - padding: 8px 0; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + margin: var(--space-2) clamp(var(--space-3), 4vw, var(--space-10)); + padding: var(--space-2) var(--space-3); + border: var(--border-width-hairline) solid var(--border-soft); + border-radius: var(--radius-element); + background: var(--surface-raised); +} + +.maka-transcript-history-copy { + display: flex; + min-width: min(100%, 28rem); + flex: 1 1 28rem; + flex-direction: column; + gap: var(--space-0-5); +} + +.maka-transcript-history-title { + font: var(--maka-text-label); + color: var(--foreground); +} + +.maka-transcript-history-description { + font: var(--maka-text-supporting); + color: var(--muted-foreground); } diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 25fc0453d4..c3c50b0373 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -179,6 +179,20 @@ transform: scaleX(1); } +/* Complete landmark indexes can outlive the bounded transcript bodies they + point at. Keep unloaded prompts actionable, but draw them as outlines so the + rail cannot imply that every landmark is already present in the DOM. */ +.maka-prompt-rail-tick[data-resident="false"] .maka-prompt-rail-tick-bar { + box-sizing: border-box; + border: var(--border-width-hairline) solid currentColor; + background: transparent; +} + +.maka-prompt-rail-tick[data-resident="false"]:is(:hover, [data-active="true"]) + .maka-prompt-rail-tick-bar { + background: currentColor; +} + /* HoverCard content. Astryx owns the card itself — surface, radius, shadow, padding — so these rules only stack and clamp the two lines and set the two text tiers, which stay on the same foreground aliases the transcript uses. */ @@ -208,3 +222,7 @@ -webkit-box-orient: vertical; overflow: hidden; } + +.maka-prompt-rail-preview-residency { + color: var(--muted-foreground); +} diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 542e276246..1e0f1a080d 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -19,9 +19,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; import { holdJumpDestination, + mergePromptAnchorRailTurns, observeActivePromptRailVisibility, + PromptAnchorRail, type PromptRailFrameScheduler, } from '../prompt-anchor-rail.js'; @@ -263,6 +268,95 @@ test('keeps the active tick visible when the rail viewport resizes', () => { assert.equal(disconnected, true); }); +test('marks complete-index landmarks outside the resident transcript range', () => { + const turns = mergePromptAnchorRailTurns( + [ + { turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }, + { turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer 3' }, + ], + [ + { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, + { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, + { turnId: 'turn-3', sequence: 4, label: 'Prompt 3' }, + ], + ); + + assert.deepEqual(turns, [ + { + turnId: 'turn-1', + label: 'Prompt 1', + reply: 'Answer 1', + sequence: 0, + isResident: true, + }, + { + turnId: 'turn-2', + label: 'Prompt 2', + reply: '', + sequence: 2, + isResident: false, + }, + { + turnId: 'turn-3', + label: 'Prompt 3', + reply: 'Answer 3', + sequence: 4, + isResident: true, + }, + ]); +}); + +test('treats every projected turn as resident without a durable landmark index', () => { + assert.deepEqual( + mergePromptAnchorRailTurns([ + { turnId: 'overlay-turn', label: 'Streaming prompt', reply: '' }, + ]), + [{ + turnId: 'overlay-turn', + label: 'Streaming prompt', + reply: '', + isResident: true, + }], + ); +}); + +test('updates a landmark when its body enters a later resident range', () => { + const index = [ + { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, + { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, + ]; + const historical = mergePromptAnchorRailTurns( + [{ turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }], + index, + ); + const intermediate = mergePromptAnchorRailTurns( + [{ turnId: 'turn-2', label: 'Prompt 2', reply: 'Answer 2' }], + index, + ); + + assert.deepEqual(historical.map((turn) => turn.isResident), [true, false]); + assert.deepEqual(intermediate.map((turn) => turn.isResident), [false, true]); +}); + +test('renders unloaded landmarks as actionable load targets', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(PromptAnchorRail, { + turns: [ + { turnId: 'turn-1', label: 'Prompt 1', sequence: 0, isResident: true }, + { turnId: 'turn-2', label: 'Prompt 2', sequence: 2, isResident: false }, + { turnId: 'turn-3', label: 'Prompt 3', sequence: 4, isResident: true }, + ], + scrollRef: { current: null }, + }), + })); + + assert.match(markup, /data-prompt-turn-id="turn-2"/); + assert.match(markup, /data-resident="false"/); + assert.match(markup, /aria-label="Load and jump to prompt: Prompt 2"/); + assert.doesNotMatch(markup, /aria-disabled="true"/); +}); + function box(top: number, bottom: number): DOMRect { return { top, bottom } as DOMRect; } diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx new file mode 100644 index 0000000000..4d50cf313d --- /dev/null +++ b/packages/ui/src/__tests__/transcript-history-notice.test.tsx @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { TranscriptHistoryNotice } from '../chat-view.js'; + +function renderNotice(isPending: boolean): string { + return renderToStaticMarkup( + undefined} + />, + ); +} + +test('explains a partial historical range as persistent accessible status', () => { + const markup = renderNotice(false); + + assert.match(markup, /role="status"/); + assert.match(markup, /aria-live="polite"/); + assert.match(markup, /aria-atomic="true"/); + assert.match(markup, /Viewing an earlier part of this conversation/); + assert.match(markup, /Newer messages are still saved, but are not currently loaded\./); + assert.match(markup, /Return to latest/); + assert.doesNotMatch(markup, /disabled/); +}); + +test('keeps the explanation visible while return-to-latest is pending', () => { + const markup = renderNotice(true); + + assert.match(markup, /Viewing an earlier part of this conversation/); + assert.match(markup, /Newer messages are still saved, but are not currently loaded\./); + assert.match(markup, /disabled/); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0102d5f6e1..a5ee3d9005 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -25,7 +25,11 @@ import { } from './icons.js'; import { DeepResearchEmptyHero, EmptyChatHero } from './chat-empty-hero.js'; import type { ChatModelChoice } from './chat-model-helpers.js'; -import { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; +import { + mergePromptAnchorRailTurns, + PromptAnchorRail, + type PromptAnchorRailTurn, +} from './prompt-anchor-rail.js'; import { useMessageSelectionQuote } from './use-message-selection-quote.js'; import type { DeepResearchClientProgress } from '@maka/core/deep-research-run'; import type { ProviderType } from '@maka/core/llm-connections'; @@ -65,6 +69,46 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } +export interface TranscriptHistoryNoticeProps { + title: string; + description: string; + actionLabel: string; + isPending: boolean; + onReturnToLatest(): Promise | void; +} + +/** Persistent explanation for a bounded range that omits newer durable messages. */ +export function TranscriptHistoryNotice({ + title, + description, + actionLabel, + isPending, + onReturnToLatest, +}: TranscriptHistoryNoticeProps) { + return ( +
+
+ {title} + {description} +
+
+ ); +} + /** * A user Message this client has shown but cannot yet prove is durable. * @@ -216,6 +260,8 @@ export function ChatView(props: { historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; returnToLatest?: { + title: string; + description: string; label: string; isPending: boolean; onClick(): Promise | void; @@ -400,19 +446,10 @@ export function ChatView(props: { promptRailTurnsRef.current = next; return next; }, [turns]); - const promptRailTurns = useMemo(() => { - const index = props.transcriptTurnIndex; - if (!index || index.length === 0) return loadedPromptRailTurns; - const loadedByTurnId = new Map(loadedPromptRailTurns.map((turn) => [turn.turnId, turn])); - return index.map((turn) => ({ - ...(loadedByTurnId.get(turn.turnId) ?? { - turnId: turn.turnId, - label: turn.label, - reply: '', - }), - sequence: turn.sequence, - })); - }, [loadedPromptRailTurns, props.transcriptTurnIndex]); + const promptRailTurns = useMemo( + () => mergePromptAnchorRailTurns(loadedPromptRailTurns, props.transcriptTurnIndex), + [loadedPromptRailTurns, props.transcriptTurnIndex], + ); // Stable event wrappers (advanced-use-latest): parent handlers are // recreated per render upstream; routing through refs keeps the // memoized TurnView's function props identity-stable without @@ -627,19 +664,16 @@ export function ChatView(props: { aria-label={copy.conversationAriaLabel(props.activeSession.name)} > {props.returnToLatest ? ( -
-
+ + Promise.resolve(props.returnToLatest?.onClick()).then(() => { + setLatestNavigationNonce((nonce) => nonce + 1); + })} + /> ) : null} string; + loadPrompt: (preview: string) => string; + unloadedPrompt: string; }; } @@ -542,7 +544,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, - listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, + listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, loadPrompt: (preview) => `加载并跳到提问:${preview}`, unloadedPrompt: '当前未加载,点击后载入', }, }, en: { @@ -690,7 +692,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, - listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, + listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, loadPrompt: (preview) => `Load and jump to prompt: ${preview}`, unloadedPrompt: 'Not currently loaded; select to load', }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 37b2ba2474..5cabad2ee4 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -214,6 +214,29 @@ export interface PromptAnchorRailTurn { label: string; reply?: string; sequence?: number; + isResident: boolean; +} + +export function mergePromptAnchorRailTurns( + loadedTurns: ReadonlyArray<{ turnId: string; label: string; reply: string }>, + index?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>, +): PromptAnchorRailTurn[] { + if (!index || index.length === 0) { + return loadedTurns.map((turn) => ({ ...turn, isResident: true })); + } + const loadedByTurnId = new Map(loadedTurns.map((turn) => [turn.turnId, turn])); + return index.map((landmark) => { + const loaded = loadedByTurnId.get(landmark.turnId); + return { + ...(loaded ?? { + turnId: landmark.turnId, + label: landmark.label, + reply: '', + }), + sequence: landmark.sequence, + isResident: loaded !== undefined, + }; + }); } export interface PromptAnchorRailProps { @@ -516,6 +539,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const isActive = turn.turnId === activeTurnIdRef.current; const preview = turn.label.trim() || copy.emptyPrompt; const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); + const actionLabel = turn.isResident + ? copy.jumpToPrompt(preview) + : copy.loadPrompt(preview); const proximity = hoveredIndex === null ? HOVER_FALLOFF_TICKS @@ -529,7 +555,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe content={ {preview} - {replyPreview ? ( + {!turn.isResident ? ( + {copy.unloadedPrompt} + ) : replyPreview ? ( {replyPreview} ) : null} @@ -539,9 +567,10 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe type="button" variant="ghost" size="sm" - label={copy.jumpToPrompt(preview)} + label={actionLabel} className="maka-prompt-rail-tick" data-prompt-turn-id={turn.turnId} + data-resident={turn.isResident ? 'true' : 'false'} data-active={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined} onClick={() => jumpTo(turn)}