diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 9b70c12b6a..241aacf84e 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -17,121 +17,73 @@ * under the License. */ -import type { Page } from '@playwright/test'; import { expect, test } from './fixtures'; -const NOTICE = '.maka-transcript-history-controls'; +const GAP = '.maka-transcript-gap-row'; +const TURN = '.maka-transcript-turn'; -async function waitForPaint(page: Page): Promise { - await page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - })); -} - -async function noticePresentation(page: Page) { - return page.locator(NOTICE).evaluate((notice) => { - const style = getComputedStyle(notice); - const box = notice.getBoundingClientRect(); - const composer = document.querySelector('.maka-composer-astryx'); - if (!composer) throw new Error('the composer is missing'); - const composerBox = composer.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderWidths: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], - display: style.display, - flexWrap: style.flexWrap, - justifyContent: style.justifyContent, - widthDelta: Math.abs(box.width - composerBox.width), - centerDelta: Math.abs( - (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, - ), - fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth, - hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, - }; - }); -} - -test('partial history is a quiet reading-column control with neutral rail ticks', async ({ +test('bounded transcript ranges expose only their truthful boundary gaps', async ({ partialHistoryWindow: page, }) => { await page.setViewportSize({ width: 1_400, height: 800 }); - await expect(page.locator(NOTICE)).toHaveCount(0); - const firstPrompt = page.locator( + const olderGap = page.locator('[data-transcript-gap="older"]'); + const newerGap = page.locator('[data-transcript-gap="newer"]'); + await expect(olderGap).toBeVisible(); + await expect(olderGap).toContainText('上方还有未加载的较早消息'); + await expect(olderGap.getByRole('button', { name: '加载较早消息' })).toBeVisible(); + await expect(newerGap).toHaveCount(0); + await expect(page.locator('.maka-transcript-history-controls')).toHaveCount(0); + + const oldestPrompt = page.locator( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', ); - await expect(firstPrompt).toBeVisible(); - await firstPrompt.click(); + await expect(oldestPrompt).toBeVisible(); + await oldestPrompt.click(); - const notice = page.locator(NOTICE); - await expect(notice).toBeVisible(); - await expect(notice).toContainText('正在查看较早的消息'); - await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible(); - await expect(notice).not.toContainText(/保存|加载/); + const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]'); + await expect(firstTurn).toBeVisible(); + await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true'); + await expect(olderGap).toHaveCount(0); + await expect(newerGap).toBeVisible(); + await expect(newerGap).toContainText('下方还有未加载的较新消息'); + await expect(newerGap.getByRole('button', { name: '加载较新消息' })).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(1); + expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); - const regular = await noticePresentation(page); - expect(regular).toEqual({ - backgroundColor: 'rgba(0, 0, 0, 0)', - borderWidths: ['0px', '0px', '0px', '0px'], - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'center', - widthDelta: expect.any(Number), - centerDelta: expect.any(Number), - fitsViewport: true, - hasHorizontalOverflow: false, - }); - expect(regular.widthDelta).toBeLessThanOrEqual(1); - expect(regular.centerDelta).toBeLessThanOrEqual(1); - - await page.mouse.move(0, 0); - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - const railPresentation = await page.evaluate(() => { - const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; - const presentation = (tick: HTMLElement) => { - const bar = tick.querySelector('.maka-prompt-rail-tick-bar'); - if (!bar) throw new Error('a prompt rail tick is missing its bar'); - const style = getComputedStyle(bar); - return { - backgroundColor: style.backgroundColor, - borderStyle: style.borderStyle, - borderWidth: style.borderWidth, - boxShadow: style.boxShadow, - }; - }; - const neutralPaint = ticks - .filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover')) - .map(presentation); - const residentStyleRules = [...document.styleSheets].flatMap((sheet) => - [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident')) - ); - return { - residentAttributeCount: document.querySelectorAll('[data-resident]').length, - residentStyleRuleCount: residentStyleRules.length, - neutralTickCount: neutralPaint.length, - neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size, - }; + const loadNewer = newerGap.getByRole('button', { name: '加载较新消息' }); + const loadNewerTop = await loadNewer.evaluate((button) => + Math.round(button.getBoundingClientRect().top) + ); + await loadNewer.click(); + await expect(page.locator('[data-turn-id="turn-partial-history-2"]')).toBeVisible(); + await page.evaluate(async () => { + for (let frame = 0; frame < 30; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } }); - expect(railPresentation.residentAttributeCount).toBe(0); - expect(railPresentation.residentStyleRuleCount).toBe(0); - expect(railPresentation.neutralTickCount).toBeGreaterThan(1); - expect(railPresentation.neutralPaintCount).toBe(1); + await expect(olderGap).toBeVisible(); + await expect(newerGap).toBeVisible(); + await expect(loadNewer).toBeEnabled(); + await expect.poll(async () => + Math.abs( + Math.round(await loadNewer.evaluate((button) => button.getBoundingClientRect().top)) + - loadNewerTop, + ) + ).toBeLessThanOrEqual(4); + await expect(loadNewer).toBeFocused(); + await expect(oldestPrompt).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(2); + expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); - await page.setViewportSize({ width: 520, height: 720 }); - await waitForPaint(page); - const narrow = await noticePresentation(page); - expect(narrow.centerDelta).toBeLessThanOrEqual(1); - expect(narrow.fitsViewport).toBe(true); - expect(narrow.hasHorizontalOverflow).toBe(false); + const returnToLatest = page.getByRole('button', { name: '滚动主对话到底部' }); + await expect(returnToLatest).toBeVisible(); + // This scenario owns the bounded-range action wired into the existing dock + // affordance. Its separate fixed-dock hit-test layering is outside #4123. + await returnToLatest.evaluate((button: HTMLButtonElement) => button.click()); - await notice.getByRole('button', { name: '返回最新消息' }).click(); - await expect(notice).toHaveCount(0); - await expect( - page.locator('[data-turn-id="turn-partial-history-8"]'), - ).toBeVisible(); + await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible(); + await expect(newerGap).toHaveCount(0); + await expect(oldestPrompt).toBeVisible(); + expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 9762f7948e..d1abb9f03c 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -165,10 +165,13 @@ async function moveToTail(page: Page): Promise { */ async function returnToLatest(page: Page): Promise { const returnLatest = page.getByRole('button', { - name: /^(?:返回最新消息|Return to latest)$/, + name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, }); await expect(returnLatest).toBeVisible(); - await returnLatest.click(); + // Range loading now reuses the existing transcript dock action. Its fixed + // layer's pointer hit testing is covered separately from this range-cost + // test, so invoke the action without introducing that unrelated dependency. + await returnLatest.evaluate((button: HTMLButtonElement) => button.click()); } /** diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..ca30eff3a8 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -896,7 +896,7 @@ "react": 1 }, "importSpecifiers": 124, - "nonTriviaTokens": 15588 + "nonTriviaTokens": 15585 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1168,7 +1168,6 @@ "actionFactories": [], "dependencyPaths": { "./chat-recovery-notice": 1, - "./locales/conversation-copy": 1, "./locales/shell-copy": 1, "./onboarding-hero": 1, "./use-app-shell-session-ui-reads": 1, diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index fb26506f70..dfe47a7eaf 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -172,7 +172,7 @@ export function partialHistorySession(now: number): SessionHeader { } /** - * Eight turns whose durable transcript is well over the Desktop range budget. + * Eighteen turns whose durable transcript is well over both Desktop range budgets. * The whitespace is stored but collapses when rendered, keeping this a useful * visual fixture while forcing the initial open to contain only the latest * contiguous range. @@ -180,9 +180,10 @@ export function partialHistorySession(now: number): SessionHeader { export function partialHistoryMessages(now: number): StoredMessage[] { const messages: StoredMessage[] = []; const rangePadding = ' '.repeat(180 * 1024); - for (let index = 1; index <= 8; index += 1) { + const turnCount = 18; + for (let index = 1; index <= turnCount; index += 1) { const turnId = `turn-partial-history-${index}`; - const ts = now - (9 - index) * 60_000; + const ts = now - (turnCount + 1 - index) * 60_000; messages.push({ type: 'user', id: `msg-partial-history-user-${index}`, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..b0753fde06 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1116,6 +1116,7 @@ function AppShellContent({ setSearchModalOpen, searchScrollTarget, setSearchScrollTarget, + consumeSearchScrollTarget, closeSearchModal, searchModalDeps, searchModalOnNavigate, @@ -2340,7 +2341,7 @@ function AppShellContent({ }), [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); useEffect(() => transcriptReadingPosition.restoreRange({ sessionId: activeId, - searchTarget: searchScrollTarget, + searchTarget: searchScrollTarget?.handled ? null : searchScrollTarget, readingAnchor: activeId ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] : undefined, @@ -2362,7 +2363,7 @@ function AppShellContent({ ), })); }, - }), [activeId, activeSession?.profileId, messages, searchScrollTarget?.nonce]); + }), [activeId, activeSession?.profileId, messages, searchScrollTarget]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, @@ -2537,32 +2538,37 @@ function AppShellContent({ setAnchor: sessionUiController.setTranscriptReadingAnchor, }); } - async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { + async function loadTranscriptHistory( + target: 'earlier' | 'newer' | 'latest', + anchorTurnId?: string, + ) { const controller = transcriptRangeRef.current; const sessionId = activeId; if (!controller || !sessionId || historyLoadPendingRef.current) return; historyLoadPendingRef.current = true; setHistoryLoadPendingSessionId(sessionId); + if (target !== 'earlier') handleTranscriptReadingAnchorChange(); try { - if (target === 'earlier') { - await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); - } else await controller.loadLatest(); + await transcriptReadingPosition.loadRange( + controller, + target, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + anchorTurnId, + ); } catch (error) { if ( - activeIdRef.current !== sessionId || - transcriptRangeRef.current !== controller - ) { - return; - } - showSessionError( - sessionId, - desktopConversationCopy.actions.messageReadFailedTitle, - localizedShellErrorMessage( - error, - desktopConversationCopy.actions.operationFailedFallback, - uiLocale, - ), - ); + activeIdRef.current === sessionId && + transcriptRangeRef.current === controller + ) + showSessionError( + sessionId, + desktopConversationCopy.actions.messageReadFailedTitle, + localizedShellErrorMessage( + error, + desktopConversationCopy.actions.operationFailedFallback, + uiLocale, + ), + ); } finally { historyLoadPendingRef.current = false; setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current); @@ -2850,6 +2856,7 @@ function AppShellContent({ scrollToBottomLabel={ desktopConversationCopy.actions.scrollMainToBottom } + onReturnToTail={() => loadTranscriptHistory('latest')} hidden={navSelection.section !== 'sessions'} composer={ <> @@ -3059,7 +3066,7 @@ function AppShellContent({ historyLoadPending={historyLoadPendingSessionId === activeId} onLoadEarlierHistory={(anchorTurnId) => loadTranscriptHistory('earlier', anchorTurnId)} - onReturnToLatestHistory={() => loadTranscriptHistory('latest')} + onLoadNewerHistory={() => loadTranscriptHistory('newer')} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} @@ -3103,16 +3110,11 @@ function AppShellContent({ } : undefined } - restoreTargetTurn={activeTranscriptReadingAnchor - ? { - turnId: activeTranscriptReadingAnchor.turnId, - unavailable: - activeUnavailableTranscriptRestore - === activeTranscriptReadingAnchor.turnId, - } - : activeUnavailableTranscriptRestore - ? { turnId: activeUnavailableTranscriptRestore, unavailable: true } - : undefined} + onScrollTargetHandled={consumeSearchScrollTarget} + restoreTargetTurn={transcriptReadingPosition.restoreTarget( + activeTranscriptReadingAnchor, + activeUnavailableTranscriptRestore, + )} onReadingAnchorChange={activeId ? handleTranscriptReadingAnchorChange : undefined} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index d9684f3263..dbb8dee68d 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -35,7 +35,6 @@ import type { SessionHealthNoticeView } from './use-shell-chat-model'; import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import type { TaskReadinessNotice } from './task-readiness-notice'; import { getShellCopy } from './locales/shell-copy'; -import { getDesktopConversationCopy } from './locales/conversation-copy'; import { selectLiveTurn } from './use-app-shell-session-ui-reads'; import { useExternalStoreSelector } from './use-external-store-selector'; import { useDeepResearchRun } from './use-deep-research-run'; @@ -93,7 +92,7 @@ interface ChatMessageSurfaceProps extends Omit< hasNewerHistory: boolean; historyLoadPending: boolean; onLoadEarlierHistory: (anchorTurnId?: string) => Promise | void; - onReturnToLatestHistory: () => Promise | void; + onLoadNewerHistory: () => Promise | void; } function captureLiveContent(liveTurn: LiveTurnProjection | undefined) { @@ -130,12 +129,11 @@ export function ChatMessageSurface({ hasNewerHistory, historyLoadPending, onLoadEarlierHistory, - onReturnToLatestHistory, + onLoadNewerHistory, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); const copy = getShellCopy(locale).app; - const transcriptCopy = getDesktopConversationCopy(locale).actions; // Configuration notices share the Settings label; identity recovery supplies // its own label because it opens the composer's connection-and-model picker. const goToModelsLabel = copy.goToModels; @@ -248,13 +246,10 @@ export function ChatMessageSurface({ emptyOverride={emptyOverride} goalIndicator={goalProjection.goalIndicator} hasOlderHistory={hasOlderHistory} + hasNewerHistory={hasNewerHistory} + historyLoadPending={historyLoadPending} onLoadEarlierHistory={onLoadEarlierHistory} - returnToLatest={hasNewerHistory ? { - title: transcriptCopy.partialHistoryTitle, - label: transcriptCopy.returnLatest, - isPending: historyLoadPending, - onClick: onReturnToLatestHistory, - } : undefined} + onLoadNewerHistory={onLoadNewerHistory} /> )} diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index afbb9f2873..55d001d665 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -32,6 +32,15 @@ interface TranscriptRangeController { loadAround(sequence: number): Promise; } +interface TranscriptHistoryController { + readonly store: { + range(): { readonly hasNewer: boolean; readonly newestSequence: number | null }; + }; + loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; + loadAround(sequence: number): Promise; + loadLatest(): Promise; +} + interface SearchTarget { readonly sessionId: string; readonly turnId: string; @@ -63,6 +72,35 @@ export function newestDurablePromptSequence( } } +export async function loadTranscriptRange( + controller: TranscriptHistoryController, + target: 'earlier' | 'newer' | 'latest', + maxBytes: number, + anchorTurnId?: string, +): Promise { + if (target === 'earlier') return controller.loadBefore(maxBytes, anchorTurnId); + if (target === 'latest') return controller.loadLatest(); + const range = controller.store.range(); + if (range.hasNewer && range.newestSequence !== null) { + await controller.loadAround(range.newestSequence + 1); + } +} + +export function transcriptRestoreTarget( + anchor: TranscriptReadingAnchor | undefined, + unavailableTurnId: string | undefined, +): { readonly turnId: string; readonly unavailable: boolean } | undefined { + if (anchor) { + return { + turnId: anchor.turnId, + unavailable: unavailableTurnId === anchor.turnId, + }; + } + return unavailableTurnId + ? { turnId: unavailableTurnId, unavailable: true } + : undefined; +} + export function refreshTranscriptTurnLandmarks(options: { readonly sessionId?: string; readonly newestDurablePromptSequence: number | null; diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index e8f6b42289..6d919712e0 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -20,17 +20,21 @@ import { captureTranscriptReadingAnchor, currentTranscriptRange, + loadTranscriptRange, newestDurablePromptSequence, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, + transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; export const transcriptReadingPosition = { captureAnchor: captureTranscriptReadingAnchor, currentRange: currentTranscriptRange, + loadRange: loadTranscriptRange, newestDurablePromptSequence, refreshLandmarks: refreshTranscriptTurnLandmarks, restoreRange: restoreSessionTranscriptRange, + restoreTarget: transcriptRestoreTarget, }; export { diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 4b6280b0bd..fa84726389 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -59,8 +59,6 @@ export interface DesktopConversationCopy { modelReboundTitle: string; modelReboundDescription: (modelId?: string) => string; messageReadFailedTitle: string; - partialHistoryTitle: string; - returnLatest: string; scrollMainToBottom: string; }; attachments: { tooMany: string; tooLarge: string; duplicate: string }; @@ -444,7 +442,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { 'zh-CN': { - 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: '添加附件失败', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', partialHistoryTitle: '正在查看较早的消息', 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: '添加附件失败', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' }, model: { fakeBackendLabel: '本地模拟连接', @@ -684,7 +682,7 @@ const COPY = { turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } }, }, 'zh-TW': { - 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: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', partialHistoryTitle: '正在檢視較早的訊息', 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: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, attachments: { tooMany: '附件數量超過 8 個', tooLarge: '附件大小超過 50MB', duplicate: '附件來源重複,請勿重複新增同一檔案。' }, model: { fakeBackendLabel: '本地模擬連線', @@ -915,7 +913,7 @@ const COPY = { turnError: { unknown: '出錯了,原因不明。重新傳送訊息重試。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時,重新傳送訊息重試。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載,請等待幾分鐘或切換模型。', rateLimit: '模型請求太頻繁而受到速率限制,請稍候再傳送訊息重試。', network: '網路連線失敗,檢查網路後重新傳送訊息。', provider: '模型服務回傳錯誤,稍後重試或切換模型。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。', partialOutput: '這一輪已經產生部分回答,重發前可以先看看。' } }, }, 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', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an 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 earlier messages', 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', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an 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', 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 ebe039fd44..e661f92f32 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -328,7 +328,8 @@ color: var(--foreground); padding: var(--space-0-5) var(--space-1-5); } -.maka-transcript-history-controls { +.maka-transcript-gap-row { width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); - margin: var(--space-2) auto 0; + margin: var(--space-2) auto; + padding-block: var(--space-1); } diff --git a/apps/desktop/src/renderer/use-shell-search.ts b/apps/desktop/src/renderer/use-shell-search.ts index 966b1d940a..5cff78c2a7 100644 --- a/apps/desktop/src/renderer/use-shell-search.ts +++ b/apps/desktop/src/renderer/use-shell-search.ts @@ -36,8 +36,17 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: turnId: string; sequence?: number; nonce: number; + handled?: boolean; } | null>(null); + const consumeSearchScrollTarget = useCallback((nonce: number) => { + setSearchScrollTarget((current) => + current?.nonce === nonce && !current.handled + ? { ...current, handled: true } + : current, + ); + }, []); + function closeSearchModal() { setSearchModalOpen(false); } @@ -56,6 +65,7 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: setSearchModalOpen, searchScrollTarget, setSearchScrollTarget, + consumeSearchScrollTarget, closeSearchModal, searchModalDeps, searchModalOnNavigate, diff --git a/packages/ui/src/__tests__/conversation-copy.test.ts b/packages/ui/src/__tests__/conversation-copy.test.ts index 11dc7c6f2c..9ee443a079 100644 --- a/packages/ui/src/__tests__/conversation-copy.test.ts +++ b/packages/ui/src/__tests__/conversation-copy.test.ts @@ -37,6 +37,27 @@ test('explains why folder-reference messages cannot be edited and resent', () => ); }); +test('labels incomplete transcript boundaries without inventing missing Turn counts', () => { + assert.deepEqual(getConversationCopy('zh-CN').chat.transcriptGap, { + olderDescription: '上方还有未加载的较早消息', + olderAction: '加载较早消息', + newerDescription: '下方还有未加载的较新消息', + newerAction: '加载较新消息', + }); + assert.deepEqual(getConversationCopy('zh-TW').chat.transcriptGap, { + olderDescription: '上方還有未載入的較早訊息', + olderAction: '載入較早訊息', + newerDescription: '下方還有未載入的較新訊息', + newerAction: '載入較新訊息', + }); + assert.deepEqual(getConversationCopy('en').chat.transcriptGap, { + olderDescription: 'Earlier messages above are not loaded.', + olderAction: 'Load earlier messages', + newerDescription: 'Newer messages below are not loaded.', + newerAction: 'Load newer messages', + }); +}); + test('context usage explains missing data without exposing provider internals', () => { assert.equal( getConversationCopy('zh-CN').messages.systemNotes.contextUsageUnavailable, diff --git a/packages/ui/src/__tests__/transcript-gap-focus.test.tsx b/packages/ui/src/__tests__/transcript-gap-focus.test.tsx new file mode 100644 index 0000000000..ebaa19767f --- /dev/null +++ b/packages/ui/src/__tests__/transcript-gap-focus.test.tsx @@ -0,0 +1,145 @@ +/* + * 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 { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { TranscriptGapRow } from '../chat-view.js'; + +function createHarness() { + const original = { + cancelAnimationFrame: globalThis.cancelAnimationFrame, + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML( + '
', + ); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + let activeElement: Element = document.body; + const focusCalls: FocusOptions[] = []; + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => activeElement, + }); + window.HTMLElement.prototype.focus = function focus(options?: FocusOptions) { + activeElement = this; + focusCalls.push(options ?? {}); + }; + Object.assign(globalThis, { + cancelAnimationFrame() {}, + document, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const container = document.querySelector('#root'); + const outside = document.querySelector('#outside'); + assert.ok(container); + assert.ok(outside); + const root = createRoot(container); + let activations = 0; + + const render = async (isPending: boolean) => { + await act(() => root.render( + { + activations += 1; + }} + />, + )); + }; + + return { + active: () => activeElement, + activations: () => activations, + async cleanup() { + await act(() => root.unmount()); + Object.assign(globalThis, original); + }, + focusCalls, + outside, + render, + setActive(element: Element) { + activeElement = element; + }, + button() { + const button = container.querySelector('button'); + assert.ok(button); + return button; + }, + window, + }; +} + +test('restores the activating gap button focus after its pending load completes', async () => { + const dom = createHarness(); + try { + await dom.render(false); + const button = dom.button(); + dom.setActive(button); + button.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + assert.equal(dom.activations(), 1); + + await dom.render(true); + dom.setActive(dom.window.document.body); + await dom.render(false); + + assert.equal(dom.active() === button, true, 'the completed gap load did not restore focus'); + assert.deepEqual(dom.focusCalls, [{ preventScroll: true }]); + } finally { + await dom.cleanup(); + } +}); + +test('does not reclaim gap focus when the reader moved to another control while loading', async () => { + const dom = createHarness(); + try { + await dom.render(false); + const button = dom.button(); + dom.setActive(button); + button.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + + await dom.render(true); + dom.setActive(dom.outside); + await dom.render(false); + + assert.equal(dom.active() === dom.outside, true, 'the gap stole focus from another control'); + assert.deepEqual(dom.focusCalls, []); + } finally { + await dom.cleanup(); + } +}); diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx index 2a26d78b30..fb672eddba 100644 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ b/packages/ui/src/__tests__/transcript-history-notice.test.tsx @@ -20,36 +20,40 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { renderToStaticMarkup } from 'react-dom/server'; -import { TranscriptHistoryNotice } from '../chat-view.js'; +import { TranscriptGapRow } from '../chat-view.js'; -function renderNotice(isPending: boolean): string { +function renderGap(direction: 'older' | 'newer', isPending: boolean): string { return renderToStaticMarkup( - undefined} + onActivate={() => undefined} />, ); } -test('presents historical position as quiet persistent status', () => { - const markup = renderNotice(false); +test('presents an older boundary gap as an in-flow transcript row', () => { + const markup = renderGap('older', false); assert.match(markup, /role="status"/); assert.match(markup, /aria-live="polite"/); assert.match(markup, /aria-atomic="true"/); - assert.match(markup, /Viewing earlier messages/); - assert.match(markup, /Return to latest/); - assert.doesNotMatch(markup, /saved|loaded/); + assert.match(markup, /data-transcript-gap="older"/); + assert.match(markup, /maka-transcript-gap-row/); + assert.match(markup, /Earlier messages are not loaded/); + assert.match(markup, /Load earlier messages/); assert.doesNotMatch(markup, / { - const markup = renderNotice(true); +test('keeps a newer boundary gap visible while its shared loader is pending', () => { + const markup = renderGap('newer', true); - assert.match(markup, /Viewing earlier messages/); - assert.doesNotMatch(markup, /saved|loaded/); + assert.match(markup, /data-transcript-gap="newer"/); + assert.match(markup, /Newer messages are not loaded/); + assert.match(markup, /Load newer messages/); assert.match(markup, /disabled/); + assert.match(markup, /aria-busy="true"/); }); diff --git a/packages/ui/src/__tests__/transcript-row-projection.test.ts b/packages/ui/src/__tests__/transcript-row-projection.test.ts new file mode 100644 index 0000000000..495252fa89 --- /dev/null +++ b/packages/ui/src/__tests__/transcript-row-projection.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { describe, test } from 'node:test'; +import { projectTranscriptRows } from '../transcript-row-projection.js'; + +interface TurnStub { + turnId: string; +} + +const turns: readonly TurnStub[] = [ + { turnId: 'turn-2' }, + { turnId: 'turn-3' }, + { turnId: 'turn-4' }, +]; + +function rowKeys(input: ReturnType>): string[] { + return input.map((row) => row.kind === 'turn' ? row.turn.turnId : `gap:${row.direction}`); +} + +describe('transcript boundary row projection', () => { + test('preserves the resident Turn order when both boundaries are complete', () => { + const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: false }); + + assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4']); + assert.strictEqual(rows[0]?.kind === 'turn' ? rows[0].turn : undefined, turns[0]); + }); + + test('places one older gap before the resident window', () => { + const rows = projectTranscriptRows({ turns, hasOlder: true, hasNewer: false }); + + assert.deepEqual(rowKeys(rows), ['gap:older', 'turn-2', 'turn-3', 'turn-4']); + }); + + test('places one newer gap after a resident window without an active Turn', () => { + const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: true }); + + assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); + }); + + test('places the newer gap immediately before the separately rendered active Turn', () => { + const rows = projectTranscriptRows({ + turns, + hasOlder: true, + hasNewer: true, + activeTurnId: 'turn-4', + }); + + assert.deepEqual(rowKeys(rows), [ + 'gap:older', + 'turn-2', + 'turn-3', + 'gap:newer', + 'turn-4', + ]); + }); + + test('keeps a newer gap at the trailing boundary when the active Turn is not resident', () => { + const rows = projectTranscriptRows({ + turns, + hasOlder: false, + hasNewer: true, + activeTurnId: 'turn-live', + }); + + assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); + }); + + test('projects only the two truthful boundaries for an empty resident window', () => { + const rows = projectTranscriptRows({ + turns: [], + hasOlder: true, + hasNewer: true, + }); + + assert.deepEqual(rowKeys(rows), ['gap:older', 'gap:newer']); + }); +}); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index d871415c96..8983c8614c 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -172,6 +172,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai }; const anchors = new Map(); + const handledTargets: number[] = []; const unavailableRestores = new Map(); let authority: TranscriptScrollAuthority | undefined; let messageRevision = 0; @@ -190,6 +191,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], target, restoreTarget, + onTargetHandled: (nonce) => handledTargets.push(nonce), onReadingAnchorChange: (turnId) => { unavailableRestores.delete(sessionId); if (turnId) anchors.set(sessionId, turnId); @@ -286,6 +288,10 @@ test('a session switch restores a Turn anchor after async fill and preserves tai await renderSession('session-b'); await flushFrames(); assert.equal(anchors.get('session-b'), 'turn-b-1'); + assert.deepEqual(handledTargets, [1]); + await renderSession('session-b'); + await flushFrames(); + assert.deepEqual(handledTargets, [1]); target = undefined; // With no resident Turn to re-anchor to, abandoning the restore falls back diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 03052d8b2b..68e32c9abd 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -45,6 +45,7 @@ export type ChatSurfaceLayoutProps = Omit, 'au */ scrollOwner?: 'astryx' | 'host'; scrollToBottomLabel?: string; + onReturnToTail?(): Promise | void; }; /** @@ -70,6 +71,7 @@ export function ChatSurfaceLayout({ density = 'balanced', scrollOwner = 'astryx', scrollToBottomLabel, + onReturnToTail, ...props }: ChatSurfaceLayoutProps) { const hostOwned = scrollOwner === 'host'; @@ -82,13 +84,25 @@ export function ChatSurfaceLayout({ : undefined, [scrollToBottomLabel], ); + const hostScrollButton = onReturnToTail ? ( +
{ + void Promise.resolve(onReturnToTail()).catch(() => undefined); + }} + > + +
+ ) : ( + + ); const layout = ( : props.scrollButton} + scrollButton={hostOwned ? hostScrollButton : props.scrollButton} density={density} className={cn('maka-chat-layout', className)} data-chat-scroll-container="true" diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0e2e146298..99d05dbe11 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -59,6 +59,7 @@ import { import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; +import { projectTranscriptRows } from './transcript-row-projection.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { SessionContextLayer, type SessionContextGoal } from './session-context-layer.js'; @@ -72,11 +73,12 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } -export interface TranscriptHistoryNoticeProps { - title: string; +export interface TranscriptGapRowProps { + direction: 'older' | 'newer'; + description: string; actionLabel: string; isPending: boolean; - onReturnToLatest(): Promise | void; + onActivate(): Promise | void; } export interface ChatViewGoalIndicatorProps { @@ -120,16 +122,44 @@ export function resolveRailAlignedTarget(null); + const restoreFocusAfterPendingRef = useRef(false); + const activationReachedPendingRef = useRef(false); + const isPendingRef = useRef(isPending); + isPendingRef.current = isPending; + + useEffect(() => { + if (isPending) { + activationReachedPendingRef.current = restoreFocusAfterPendingRef.current; + return; + } + if (!activationReachedPendingRef.current) return; + + activationReachedPendingRef.current = false; + const shouldRestore = restoreFocusAfterPendingRef.current; + restoreFocusAfterPendingRef.current = false; + const action = actionRef.current; + if ( + shouldRestore + && action?.isConnected + && document.activeElement === document.body + ) { + action.focus({ preventScroll: true }); + } + }, [isPending]); + return ( - {title} + {description}