diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index bdc49d7bde..0f1eeb29a9 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -17,6 +17,10 @@ "tests": 1, "electron": "renderer reload is the whole contract: an explicit new task must not reopen history" }, + "partial-history-notice.spec.ts": { + "tests": 1, + "electron": "bounded paging crosses renderer, preload and Host transcript storage; the return-to-tail assertion also uses a real pointer hit-test" + }, "proxy-password-editing.spec.ts": { "tests": 1, "electron": "the password never reaches the renderer; only the Host can report passwordConfigured and authenticate offline" diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index b3eac80252..e19d1ec32d 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -523,6 +523,7 @@ type E2eTestFixtures = { parentRemovalWindow: Page; railRenderWindow: Page; promptRailWindow: Page; + partialHistoryWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -640,6 +641,17 @@ export const test = base.extend({ showWindow: true, }, use); }, + // A transcript larger than the bounded Desktop range. Clicking an unloaded + // prompt exercises the real load-around path and its partial-history UI. + partialHistoryWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-partial-history', + locale: 'zh-CN', + showWindow: true, + }, use); + }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts new file mode 100644 index 0000000000..5179ef42f7 --- /dev/null +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -0,0 +1,84 @@ +/* + * 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 { expect, test } from './fixtures'; + +const GAP = '.maka-transcript-gap-row'; +const TURN = '.maka-transcript-turn'; + +test('bounded transcript ranges expose only their truthful boundary gaps', async ({ + partialHistoryWindow: page, +}) => { + await page.setViewportSize({ width: 1_400, height: 800 }); + + const olderGap = page.locator('[data-transcript-gap="older"]'); + const newerGap = page.locator('[data-transcript-gap="newer"]'); + await expect(olderGap).toBeVisible(); + await expect(olderGap.getByRole('button', { + name: /^(?:加载较早消息|Load earlier messages)$/, + })).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(oldestPrompt).toBeVisible(); + await oldestPrompt.click(); + + 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.getByRole('button', { + name: /^(?:加载较新消息|Load newer messages)$/, + })).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(1); + expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + + const loadNewer = newerGap.getByRole('button', { + name: /^(?:加载较新消息|Load newer messages)$/, + }); + await loadNewer.click(); + await expect(page.locator('[data-turn-id="turn-partial-history-2"]')).toBeVisible(); + await expect(olderGap).toHaveCount(0); + await expect(newerGap).toBeVisible(); + await expect(loadNewer).toBeEnabled(); + + await loadNewer.click(); + await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); + await expect(olderGap).toBeVisible(); + await expect(newerGap).toBeVisible(); + await expect(loadNewer).toBeEnabled(); + await expect(oldestPrompt).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(2); + expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + + const returnToLatest = page.getByRole('button', { + name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, + }); + await expect(returnToLatest).toBeVisible(); + await returnToLatest.click(); + + 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 b6954c2486..01899a42a5 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -165,7 +165,7 @@ 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(); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8314baf33e..85906cc9e0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -893,7 +893,7 @@ "react": 1 }, "importSpecifiers": 121, - "nonTriviaTokens": 15007 + "nonTriviaTokens": 14996 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1157,7 +1157,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/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index d155511542..3a2e76ebd8 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -34,6 +34,7 @@ import { import { transcriptReadingPosition, type TranscriptHistoryGates, + type TranscriptHistoryPending, } from '../../renderer/features/conversation/index.js'; function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent { @@ -102,19 +103,26 @@ function deferredHistoryController() { } function crossSessionGateScenario() { + type HistoryRequest = Parameters[0]['request']; const gates: TranscriptHistoryGates = new WeakMap(); + const sessionIds = { a: 'session', b: 'session:a' } as const; const sides = { a: deferredHistoryController(), b: deferredHistoryController(), }; let active: 'a' | 'b' = 'a'; let range: object = sides.a.controller; - const pending = { a: [] as boolean[], b: [] as boolean[] }; + let currentPending: TranscriptHistoryPending | undefined; + const pending = { + a: [] as Array | undefined>, + b: [] as Array | undefined>, + }; const errors = { a: [] as unknown[], b: [] as unknown[] }; return { sides, pending, errors, + currentPending: () => currentPending, switchTo(id: 'a' | 'b') { active = id; range = sides[id].controller; @@ -126,11 +134,17 @@ function crossSessionGateScenario() { const side = sides[id]; return transcriptReadingPosition.loadHistory({ gates, + sessionId: sessionIds[id], request, controller: side.controller, maxBytes: 4096, isCurrent: () => active === id && range === side.controller, - setPending: (value) => pending[id].push(value), + setPending: (update) => { + currentPending = update(currentPending); + pending[id].push(currentPending?.sessionId === sessionIds[id] + ? { target: currentPending.target } + : undefined); + }, onError: (error) => errors[id].push(error), }); }, @@ -524,18 +538,19 @@ describe('app shell session UI state controller', () => { const stale = scenario.load('a', { target: 'earlier' }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(scenario.sides.a.calls, ['before']); - assert.deepEqual(scenario.pending.a, [true]); + assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); scenario.switchTo('b'); const navigation = scenario.load('b', { target: 'latest' }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(scenario.sides.b.calls, ['latest']); - assert.deepEqual(scenario.pending.b, [true]); + assert.deepEqual(scenario.pending.b, [{ target: 'latest' }]); - scenario.sides.b.settleLatest(); scenario.sides.a.settleBefore(); - await navigation; await stale; + assert.deepEqual(scenario.currentPending(), { sessionId: 'session:a', target: 'latest' }); + scenario.sides.b.settleLatest(); + await navigation; }); it('leaves the switched-to Session untouched when a stale Session load settles late', async () => { @@ -546,13 +561,13 @@ describe('app shell session UI state controller', () => { const navigation = scenario.load('b', { target: 'latest' }); scenario.sides.b.settleLatest(); await navigation; - assert.deepEqual(scenario.pending.b, [true, false]); + assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); assert.deepEqual(scenario.sides.b.calls, ['latest']); scenario.sides.a.settleBefore(); await stale; await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.pending.b, [true, false]); + assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); assert.deepEqual(scenario.sides.b.calls, ['latest']); assert.deepEqual(scenario.errors.b, []); }); @@ -565,7 +580,7 @@ describe('app shell session UI state controller', () => { scenario.sides.a.failBefore(new Error('earlier read failed')); await stale; assert.deepEqual(scenario.errors.a, []); - assert.deepEqual(scenario.pending.a, [true, false]); + assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]); assert.deepEqual(scenario.pending.b, []); }); @@ -582,7 +597,12 @@ describe('app shell session UI state controller', () => { assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); scenario.sides.a.settleLatest(); await Promise.allSettled([queuedEarlier, queuedLatest]); - assert.deepEqual(scenario.pending.a, [true, false, true, false]); + assert.deepEqual(scenario.pending.a, [ + { target: 'earlier' }, + undefined, + { target: 'latest' }, + undefined, + ]); }); it('replays a queued forward load with its reading anchor after a backward load settles', async () => { @@ -596,7 +616,12 @@ describe('app shell session UI state controller', () => { assert.deepEqual(scenario.sides.a.calls, ['before', 'after:4096:turn-anchor']); scenario.sides.a.settleAfter(); await queued; - assert.deepEqual(scenario.pending.a, [true, false, true, false]); + assert.deepEqual(scenario.pending.a, [ + { target: 'earlier' }, + undefined, + { target: 'later' }, + undefined, + ]); }); it('keeps the queued latest load when adjacent requests arrive after it', async () => { @@ -613,7 +638,12 @@ describe('app shell session UI state controller', () => { assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); scenario.sides.a.settleLatest(); await Promise.allSettled([queuedLatest, queuedEarlier, queuedLater]); - assert.deepEqual(scenario.pending.a, [true, false, true, false]); + assert.deepEqual(scenario.pending.a, [ + { target: 'earlier' }, + undefined, + { target: 'latest' }, + undefined, + ]); }); it('does not replay a settled load after its Session range was replaced', async () => { @@ -627,7 +657,7 @@ describe('app shell session UI state controller', () => { await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(scenario.sides.a.calls, ['before']); assert.deepEqual(scenario.sides.b.calls, []); - assert.deepEqual(scenario.pending.a, [true, false]); + assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]); scenario.sides.a.settleLatest(); await queued; }); @@ -642,7 +672,12 @@ describe('app shell session UI state controller', () => { assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); scenario.sides.a.settleLatest(); await queued; - assert.deepEqual(scenario.pending.a, [true, false, true, false]); + assert.deepEqual(scenario.pending.a, [ + { target: 'earlier' }, + undefined, + { target: 'latest' }, + undefined, + ]); assert.deepEqual(scenario.pending.b, []); assert.deepEqual(scenario.sides.b.calls, []); }); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index d253fd472b..5be7c0ed42 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -35,11 +35,14 @@ import { LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, + PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, writeSession, } from './e2e-fixture/seed-helpers.js'; import { + partialHistoryMessages, + partialHistorySession, promptRailMessages, promptRailSession, turnMessages, @@ -60,6 +63,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative', 'turn-narrative-browser', 'chat-prompt-rail', + 'chat-partial-history', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -169,6 +173,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState // Workbar collapsed: the rail lives on the chat scrollport's right edge, // and the panel would take the width the measurements are about. return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; + case 'chat-partial-history': + return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -224,6 +230,13 @@ export async function seedE2eFixture(input: { if (scenario === 'chat-prompt-rail') { await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); } + if (scenario === 'chat-partial-history') { + await writeSession( + input.workspaceRoot, + partialHistorySession(now), + partialHistoryMessages(now), + ); + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index dce4fb2b3b..763b91ada0 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,6 +20,7 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, + PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, @@ -148,3 +149,45 @@ export function promptRailMessages(now: number): StoredMessage[] { } return messages; } +export function partialHistorySession(now: number): SessionHeader { + return header({ + id: PARTIAL_HISTORY_SESSION_ID, + name: '超长对话历史范围示例', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 60_000, + }); +} + +/** + * 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. + */ +export function partialHistoryMessages(now: number): StoredMessage[] { + const messages: StoredMessage[] = []; + const rangePadding = ' '.repeat(180 * 1024); + const turnCount = 18; + for (let index = 1; index <= turnCount; index += 1) { + const turnId = `turn-partial-history-${index}`; + const ts = now - (turnCount + 1 - index) * 60_000; + messages.push({ + type: 'user', + id: `msg-partial-history-user-${index}`, + turnId, + ts, + text: `第 ${index} 个问题:请概括这一阶段的实现进展。`, + }); + messages.push({ + type: 'assistant', + id: `msg-partial-history-assistant-${index}`, + turnId, + ts: ts + 1_000, + text: `第 ${index} 阶段已经完成关键实现,并通过了对应验证。${rangePadding}`, + modelId: 'glm-5.1', + }); + } + return messages; +} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 6367161d6d..1e1cd6a535 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -34,6 +34,7 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; +export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ed3602c3f2..ec4596a269 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -78,6 +78,7 @@ import { resolveTaskReadinessModelTarget, transcriptReadingPosition, type TranscriptHistoryGates, + type TranscriptHistoryPending, } from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; @@ -438,11 +439,7 @@ function AppShellContent({ const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); - // The state above is what the transcript renders; this is what the guard - // reads. A scroller can ask twice in one task — two scroll events before - // React has re-rendered anything — and a state read is still the old value - // for both of them. + const [historyLoadPending, setHistoryLoadPending] = useState(); const historyLoadGatesRef = useRef(new WeakMap()); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -1019,6 +1016,7 @@ function AppShellContent({ setSearchModalOpen, searchScrollTarget, setSearchScrollTarget, + consumeSearchScrollTarget, closeSearchModal, searchModalDeps, searchModalOnNavigate, @@ -2242,7 +2240,7 @@ function AppShellContent({ }), [ownerActiveId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); useEffect(() => transcriptReadingPosition.restoreRange({ sessionId: activeId, - searchTarget: searchScrollTarget, + searchTarget: searchScrollTarget?.handled ? null : searchScrollTarget, readingAnchor: activeId ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] : undefined, @@ -2264,7 +2262,7 @@ function AppShellContent({ ), })); }, - }), [activeId, activeSession?.profileId, messages, searchScrollTarget?.nonce]); + }), [activeId, activeSession?.profileId, messages, searchScrollTarget]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, @@ -2443,14 +2441,15 @@ function AppShellContent({ const controller = transcriptRangeRef.current; const sessionId = activeId; if (!controller || !sessionId) return; + if (target !== 'earlier') handleTranscriptReadingAnchorChange(); return transcriptReadingPosition.loadHistory({ gates: historyLoadGatesRef.current, + sessionId, request: { target, anchorTurnId }, controller, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, isCurrent: () => activeIdRef.current === sessionId && transcriptRangeRef.current === controller, - setPending: (pending) => setHistoryLoadPendingSessionId((current) => - pending ? sessionId : current === sessionId ? undefined : current), + setPending: setHistoryLoadPending, onError: (error) => showSessionError( sessionId, desktopConversationCopy.actions.messageReadFailedTitle, @@ -2746,6 +2745,9 @@ function AppShellContent({ scrollToBottomLabel={ desktopConversationCopy.actions.scrollMainToBottom } + onReturnToTail={activeTranscriptRange?.hasNewer + ? () => loadTranscriptHistory('latest') + : undefined} hidden={navSelection.section !== 'sessions'} composer={ <> @@ -2954,9 +2956,9 @@ function AppShellContent({ sessionId ? state.shellRunUpdatesBySession[sessionId] : undefined; @@ -62,6 +62,7 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurn' | 'shellRunUpdates' | 'goalIndicator' + | 'historyLoadPending' > { /** * #1985: the live projection and the shell-run records are the only session @@ -89,9 +90,9 @@ interface ChatMessageSurfaceProps extends Omit< connections: LlmConnection[]; onRefreshConnections: () => Promise | void; onSkip: () => Promise | void; - hasOlderHistory: boolean; - hasNewerHistory: boolean; - historyLoadPending: boolean; + hasOlderHistory?: boolean; + hasNewerHistory?: boolean; + historyLoadPending?: TranscriptHistoryPending; onLoadHistory: (target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) => Promise | void; } @@ -133,7 +134,6 @@ export function ChatMessageSurface({ }: 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; @@ -246,15 +246,12 @@ export function ChatMessageSurface({ emptyOverride={emptyOverride} goalIndicator={goalProjection.goalIndicator} hasOlderHistory={hasOlderHistory} - onLoadEarlierHistory={(anchorTurnId) => onLoadHistory('earlier', anchorTurnId)} hasNewerHistory={hasNewerHistory} + historyLoadPending={historyLoadPending && historyLoadPending.sessionId === activeSessionId + ? historyLoadPending.target === 'earlier' ? 'older' : 'newer' + : undefined} + onLoadEarlierHistory={(anchorTurnId) => onLoadHistory('earlier', anchorTurnId)} onLoadLaterHistory={(anchorTurnId) => onLoadHistory('later', anchorTurnId)} - returnToLatest={hasNewerHistory ? { - title: transcriptCopy.partialHistoryTitle, - label: transcriptCopy.returnLatest, - isPending: historyLoadPending, - onClick: () => onLoadHistory('latest'), - } : undefined} /> )} 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 ac9d583486..fecf20811f 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 @@ -63,6 +63,21 @@ export function newestDurablePromptSequence( } } +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; @@ -104,17 +119,32 @@ export interface TranscriptHistoryRequest { readonly anchorTurnId?: string; } +export interface TranscriptHistoryPending { + readonly sessionId: string; + readonly target: TranscriptHistoryRequest['target']; +} + export interface TranscriptHistoryGate { pending: boolean; queued?: TranscriptHistoryRequest; } +function updateTranscriptHistoryPending( + current: TranscriptHistoryPending | undefined, + sessionId: string, + request: TranscriptHistoryRequest | undefined, +): TranscriptHistoryPending | undefined { + if (request) return { sessionId, target: request.target }; + return current?.sessionId === sessionId ? undefined : current; +} + /** One gate per controller: the shell rebuilds the controller per Session, so * keying by it keeps Sessions from queuing behind each other's loads. */ export type TranscriptHistoryGates = WeakMap; export async function loadTranscriptHistory(options: { readonly gates: TranscriptHistoryGates; + readonly sessionId: string; readonly request: TranscriptHistoryRequest; readonly controller: { loadBefore(maxBytes: number, anchorTurnId?: string): Promise; @@ -123,7 +153,11 @@ export async function loadTranscriptHistory(options: { }; readonly maxBytes: number; readonly isCurrent: () => boolean; - readonly setPending: (pending: boolean) => void; + readonly setPending: ( + update: ( + current: TranscriptHistoryPending | undefined, + ) => TranscriptHistoryPending | undefined, + ) => void; readonly onError: (error: unknown) => void; }): Promise { const { gates, controller, request } = options; @@ -136,7 +170,8 @@ export async function loadTranscriptHistory(options: { return; } gate.pending = true; - options.setPending(true); + options.setPending((current) => + updateTranscriptHistoryPending(current, options.sessionId, request)); try { if (request.target === 'latest') await controller.loadLatest(); else await controller[request.target === 'earlier' ? 'loadBefore' : 'loadAfter']( @@ -146,7 +181,8 @@ export async function loadTranscriptHistory(options: { if (options.isCurrent()) options.onError(error); } finally { gate.pending = false; - options.setPending(false); + options.setPending((current) => + updateTranscriptHistoryPending(current, options.sessionId, undefined)); const queued = gate.queued; gate.queued = undefined; if (queued && options.isCurrent()) void loadTranscriptHistory({ ...options, request: queued }); diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index c2ca8a0730..0c032dcfc2 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -24,6 +24,7 @@ import { newestDurablePromptSequence, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, + transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; export const transcriptReadingPosition = { @@ -33,9 +34,13 @@ export const transcriptReadingPosition = { newestDurablePromptSequence, refreshLandmarks: refreshTranscriptTurnLandmarks, restoreRange: restoreSessionTranscriptRange, + restoreTarget: transcriptRestoreTarget, }; -export type { TranscriptHistoryGates } from './controller/transcript-reading-position.js'; +export type { + TranscriptHistoryGates, + TranscriptHistoryPending, +} from './controller/transcript-reading-position.js'; export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked, 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 50c376205e..1971af327a 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -372,7 +372,8 @@ color: var(--foreground); padding: var(--space-0-5) var(--space-1-5); } -.maka-transcript-history-controls { - width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); - margin: var(--space-2) auto 0; +.maka-transcript-gap-row { + width: min(var(--maka-reading-measure), 100%); + 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/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f39dd02b14..208c39d5a6 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1807,27 +1807,22 @@ function PartialHistoryHarness() { setReadingEarlier(true), - returnToLatest: readingEarlier - ? { - title: '正在查看较早的消息', - label: '返回最新消息', - isPending: false, - onClick: () => setReadingEarlier(false), - } - : undefined, + hasOlderHistory: !readingEarlier, + hasNewerHistory: readingEarlier, + onLoadLaterHistory: () => setReadingEarlier(false), }} /> ); } -function historyNoticePresentation(notice: HTMLElement) { - const style = getComputedStyle(notice); - const box = notice.getBoundingClientRect(); +function historyGapPresentation(gap: HTMLElement) { + const style = getComputedStyle(gap); + const box = gap.getBoundingClientRect(); const composer = document.querySelector('.maka-composer-astryx'); - const frame = notice.closest('.appFrame'); + const frame = gap.closest('.appFrame'); if (!composer || !frame) throw new Error('The shell geometry is incomplete'); const composerBox = composer.getBoundingClientRect(); const frameBox = frame.getBoundingClientRect(); @@ -1847,17 +1842,20 @@ function historyNoticePresentation(notice: HTMLElement) { (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, ), fitsFrame: box.left >= frameBox.left && box.right <= frameBox.right, - hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, + clientWidth: gap.clientWidth, + scrollWidth: gap.scrollWidth, + hasHorizontalOverflow: gap.scrollWidth > gap.clientWidth, }; } // Real path: selecting a prompt outside the loaded transcript range, then -// returning to the latest range. The notice stays a quiet reading-column +// loading the newer range. Each boundary stays a quiet reading-column // control and every inactive prompt-rail tick uses one neutral treatment. export const PartialHistoryNotice: Story = { render: () => , play: async ({ canvasElement }) => { - expect(canvasElement.querySelector('.maka-transcript-history-controls')).toBeNull(); + expect(canvasElement.querySelector('[data-transcript-gap="older"]')).not.toBeNull(); + expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); const firstPrompt = canvasElement.querySelector( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', ); @@ -1865,14 +1863,14 @@ export const PartialHistoryNotice: Story = { firstPrompt.click(); await waitFor(() => { - expect(canvasElement.querySelector('.maka-transcript-history-controls')).not.toBeNull(); + expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).not.toBeNull(); }); - const notice = canvasElement.querySelector('.maka-transcript-history-controls'); - if (!notice) throw new Error('The partial-history notice did not render'); - expect(notice.textContent).toContain('正在查看较早的消息'); - expect(notice.textContent).not.toMatch(/保存|加载/); + const gap = canvasElement.querySelector('[data-transcript-gap="newer"]'); + if (!gap) throw new Error('The newer transcript gap did not render'); + expect(gap.textContent).toContain('下方还有未加载的较新消息'); + expect(gap.textContent).toContain('加载较新消息'); - const regular = historyNoticePresentation(notice); + const regular = historyGapPresentation(gap); expect(regular.backgroundColor).toBe('rgba(0, 0, 0, 0)'); expect(regular.borderWidths).toEqual(['0px', '0px', '0px', '0px']); expect(regular.display).toBe('flex'); @@ -1880,7 +1878,7 @@ export const PartialHistoryNotice: Story = { expect(regular.justifyContent).toBe('center'); expect(regular.widthDelta).toBeLessThanOrEqual(1); expect(regular.centerDelta).toBeLessThanOrEqual(1); - expect(regular.hasHorizontalOverflow).toBe(false); + expect(regular.hasHorizontalOverflow, JSON.stringify(regular)).toBe(false); const neutralPaint = [ ...canvasElement.querySelectorAll('.maka-prompt-rail-tick'), @@ -1909,15 +1907,15 @@ export const PartialHistoryNotice: Story = { if (!frame) throw new Error('Shell frame did not render'); frame.style.width = '520px'; await painted(2); - const narrow = historyNoticePresentation(notice); + const narrow = historyGapPresentation(gap); expect(narrow.centerDelta).toBeLessThanOrEqual(1); expect(narrow.fitsFrame).toBe(true); - expect(narrow.hasHorizontalOverflow).toBe(false); + expect(narrow.hasHorizontalOverflow, JSON.stringify(narrow)).toBe(false); - const returnButton = within(notice).getByRole('button', { name: '返回最新消息' }); - returnButton.click(); + const loadNewerButton = within(gap).getByRole('button', { name: '加载较新消息' }); + loadNewerButton.click(); await waitFor(() => { - expect(canvasElement.querySelector('.maka-transcript-history-controls')).toBeNull(); + expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); expect(canvasElement.querySelector('[data-turn-id="turn-scroll-8"]')).not.toBeNull(); }); }, diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 2961cca642..0099c188ee 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -27,6 +27,7 @@ export type E2eFixtureScenario = | 'turn-narrative' | 'turn-narrative-browser' | 'chat-prompt-rail' + | 'chat-partial-history' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' 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__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx index 6c01c4e334..fc3de1accd 100644 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx @@ -36,7 +36,6 @@ import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import { LocaleProvider } from '../locale-context.js'; import { - TranscriptScrollAuthorityProvider, useTranscriptScrollAuthority, type TranscriptScrollAuthority, } from '../transcript-scroll-authority.js'; @@ -189,9 +188,6 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret authority = useTranscriptScrollAuthority(); return createElement(Fragment, null); }; - const probe = createElement(TranscriptScrollAuthorityProvider, { - children: createElement(AuthorityProbe), - }); const view = (): ReactElement => { const chat = createElement(ChatView, { messages: turnMessages(), @@ -199,26 +195,24 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret onNew: () => {}, scrollBehavior: 'auto' as const, hasOlderHistory: true, + hasNewerHistory: true, onLoadEarlierHistory: () => undefined, + onLoadLaterHistory: () => undefined, onReadingAnchorChange: (turnId?: string) => { anchors.push(turnId); }, - returnToLatest: { - title: '查看较早的消息', - label: '回到最新', - isPending: false, - onClick: options.onClick, - }, } as never); const layout = createElement(ChatSurfaceLayout, { scrollOwner: 'host', + scrollToBottomLabel: '回到最新', + onReturnToTail: options.onClick, composer: null, - children: chat, + children: createElement(Fragment, null, chat, createElement(AuthorityProbe)), }); const astryx = createElement(AstryxLocaleProvider, { children: layout }); return createElement(LocaleProvider, { locale: 'zh-CN', - children: createElement(Fragment, null, astryx, probe), + children: astryx, }); }; const mount = document.querySelector('#mount'); @@ -230,8 +224,7 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret }); const scrollRoot = mount.querySelector('[data-chat-scroll-container]'); assert.ok(scrollRoot, 'the layout mounts a scroll container'); - const scrollButton = [...mount.querySelectorAll('button')] - .find((button) => button.textContent?.includes('回到最新')); + const scrollButton = mount.querySelector('button[aria-label="回到最新"]'); assert.ok(scrollButton, 'the return-to-latest affordance is rendered'); // The authority attached itself to the scroller on mount and wrote the tail // into a zero-sized box, so its classification state says scrollTop=0 over a @@ -301,4 +294,4 @@ test('the anchor stays cleared while the range is still loading', async () => { click.release(); await act(async () => {}); -}); \ No newline at end of file +}); diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx index 2a26d78b30..13d67ad977 100644 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ b/packages/ui/src/__tests__/transcript-history-notice.test.tsx @@ -20,36 +20,43 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { renderToStaticMarkup } from 'react-dom/server'; -import { TranscriptHistoryNotice } from '../chat-view.js'; +import { TranscriptHistoryGapRow } 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 busy but interruptible while loading', () => { + const markup = renderGap('newer', true); - assert.match(markup, /Viewing earlier messages/); - assert.doesNotMatch(markup, /saved|loaded/); - assert.match(markup, /disabled/); + assert.match(markup, /data-transcript-gap="newer"/); + assert.match(markup, /Newer messages are not loaded/); + assert.match(markup, /Load newer messages/); + assert.doesNotMatch(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 b6c29522e7..49c970cad5 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -265,6 +265,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; @@ -283,6 +284,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); @@ -379,6 +381,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..35d2b8fee7 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -45,6 +45,8 @@ export type ChatSurfaceLayoutProps = Omit, 'au */ scrollOwner?: 'astryx' | 'host'; scrollToBottomLabel?: string; + /** Loads the durable tail after the scroll authority pins to it. */ + onReturnToTail?(): Promise | void; }; /** @@ -70,6 +72,7 @@ export function ChatSurfaceLayout({ density = 'balanced', scrollOwner = 'astryx', scrollToBottomLabel, + onReturnToTail, ...props }: ChatSurfaceLayoutProps) { const hostOwned = scrollOwner === 'host'; @@ -88,7 +91,9 @@ export function ChatSurfaceLayout({ autoScroll={!hostOwned} // Astryx's default button reads `isScrolledUp`, which stops updating the // moment its scroll layer is off. Maka's reads Maka's pin instead. - scrollButton={hostOwned ? : props.scrollButton} + scrollButton={hostOwned + ? + : 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 699625377e..d4479ddd0a 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,14 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } -export interface TranscriptHistoryNoticeProps { - title: string; +export type TranscriptHistoryLoadDirection = 'older' | 'newer'; + +export interface TranscriptHistoryGapRowProps { + direction: TranscriptHistoryLoadDirection; + description: string; actionLabel: string; isPending: boolean; - onReturnToLatest(): Promise | void; + onActivate(): Promise | void; } export interface ChatViewGoalIndicatorProps { @@ -120,16 +124,18 @@ export function resolveRailAlignedTarget - {title} + {description}