From 2aab0f3b2c514c109280ca96dd23fc287a24d7ae Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:25:59 +0800 Subject: [PATCH 1/3] fix(desktop): simplify partial history notice Generated-by: OpenAI Codex --- .../src/renderer/chat-message-surface.tsx | 1 - .../src/renderer/locales/conversation-copy.ts | 5 ++- .../src/renderer/styles/chat-message.css | 31 ++----------------- .../src/renderer/styles/prompt-rail.css | 18 ----------- .../src/__tests__/prompt-anchor-rail.test.ts | 27 +++++++--------- .../transcript-history-notice.test.tsx | 16 +++++----- packages/ui/src/chat-view.tsx | 21 ++++++------- packages/ui/src/conversation-copy.ts | 6 ++-- packages/ui/src/prompt-anchor-rail.tsx | 14 ++------- 9 files changed, 38 insertions(+), 101 deletions(-) diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index eb0dc1a917..cbffc0db7c 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -249,7 +249,6 @@ export function ChatMessageSurface({ onLoadEarlierHistory={onLoadEarlierHistory} returnToLatest={hasNewerHistory ? { title: transcriptCopy.partialHistoryTitle, - description: transcriptCopy.partialHistoryDescription, label: transcriptCopy.returnLatest, isPending: historyLoadPending, onClick: onReturnToLatestHistory, diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index de6adcdb1c..24ea1a230c 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -58,7 +58,6 @@ export interface DesktopConversationCopy { modelReboundDescription: (modelId?: string) => string; messageReadFailedTitle: string; partialHistoryTitle: string; - partialHistoryDescription: string; returnLatest: string; scrollMainToBottom: string; }; @@ -423,7 +422,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { zh: { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', partialHistoryTitle: '正在查看较早的一段对话', partialHistoryDescription: '较新的消息仍已保存,但当前未加载。', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', partialHistoryTitle: '正在查看较早的消息', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' }, model: { fakeBackendLabel: '本地模拟连接', @@ -651,7 +650,7 @@ const COPY = { turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', contextBudgetExhausted: '上下文已达上限,这个任务无法继续。换模型或开启新任务。', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', 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', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing an earlier part of this conversation', partialHistoryDescription: 'Newer messages are still saved, but are not currently loaded.', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing earlier messages', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' }, model: { fakeBackendLabel: 'Local simulation', diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 12f14e623a..5ecb267492 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -318,33 +318,6 @@ padding: var(--space-0-5) var(--space-1-5); } .maka-transcript-history-controls { - display: flex; - box-sizing: border-box; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: var(--space-2) var(--space-4); - margin: var(--space-2) clamp(var(--space-3), 4vw, var(--space-10)); - padding: var(--space-2) var(--space-3); - border: var(--border-width-hairline) solid var(--border-soft); - border-radius: var(--radius-element); - background: var(--surface-raised); -} - -.maka-transcript-history-copy { - display: flex; - min-width: min(100%, 28rem); - flex: 1 1 28rem; - flex-direction: column; - gap: var(--space-0-5); -} - -.maka-transcript-history-title { - font: var(--maka-text-label); - color: var(--foreground); -} - -.maka-transcript-history-description { - font: var(--maka-text-supporting); - color: var(--muted-foreground); + width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); + margin: var(--space-2) auto 0; } diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index c3c50b0373..25fc0453d4 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -179,20 +179,6 @@ transform: scaleX(1); } -/* Complete landmark indexes can outlive the bounded transcript bodies they - point at. Keep unloaded prompts actionable, but draw them as outlines so the - rail cannot imply that every landmark is already present in the DOM. */ -.maka-prompt-rail-tick[data-resident="false"] .maka-prompt-rail-tick-bar { - box-sizing: border-box; - border: var(--border-width-hairline) solid currentColor; - background: transparent; -} - -.maka-prompt-rail-tick[data-resident="false"]:is(:hover, [data-active="true"]) - .maka-prompt-rail-tick-bar { - background: currentColor; -} - /* HoverCard content. Astryx owns the card itself — surface, radius, shadow, padding — so these rules only stack and clamp the two lines and set the two text tiers, which stay on the same foreground aliases the transcript uses. */ @@ -222,7 +208,3 @@ -webkit-box-orient: vertical; overflow: hidden; } - -.maka-prompt-rail-preview-residency { - color: var(--muted-foreground); -} diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 1e0f1a080d..3812b11441 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -268,7 +268,7 @@ test('keeps the active tick visible when the rail viewport resizes', () => { assert.equal(disconnected, true); }); -test('marks complete-index landmarks outside the resident transcript range', () => { +test('merges complete-index landmarks with the resident transcript range', () => { const turns = mergePromptAnchorRailTurns( [ { turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }, @@ -287,26 +287,23 @@ test('marks complete-index landmarks outside the resident transcript range', () label: 'Prompt 1', reply: 'Answer 1', sequence: 0, - isResident: true, }, { turnId: 'turn-2', label: 'Prompt 2', reply: '', sequence: 2, - isResident: false, }, { turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer 3', sequence: 4, - isResident: true, }, ]); }); -test('treats every projected turn as resident without a durable landmark index', () => { +test('preserves every projected turn without a durable landmark index', () => { assert.deepEqual( mergePromptAnchorRailTurns([ { turnId: 'overlay-turn', label: 'Streaming prompt', reply: '' }, @@ -315,12 +312,11 @@ test('treats every projected turn as resident without a durable landmark index', turnId: 'overlay-turn', label: 'Streaming prompt', reply: '', - isResident: true, }], ); }); -test('updates a landmark when its body enters a later resident range', () => { +test('updates landmark content when its body enters a later resident range', () => { const index = [ { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, @@ -334,26 +330,27 @@ test('updates a landmark when its body enters a later resident range', () => { index, ); - assert.deepEqual(historical.map((turn) => turn.isResident), [true, false]); - assert.deepEqual(intermediate.map((turn) => turn.isResident), [false, true]); + assert.deepEqual(historical.map((turn) => turn.reply), ['Answer 1', '']); + assert.deepEqual(intermediate.map((turn) => turn.reply), ['', 'Answer 2']); }); -test('renders unloaded landmarks as actionable load targets', () => { +test('keeps unloaded landmarks visually uniform and actionable', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', children: createElement(PromptAnchorRail, { turns: [ - { turnId: 'turn-1', label: 'Prompt 1', sequence: 0, isResident: true }, - { turnId: 'turn-2', label: 'Prompt 2', sequence: 2, isResident: false }, - { turnId: 'turn-3', label: 'Prompt 3', sequence: 4, isResident: true }, + { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 }, + { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 }, + { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 }, ], scrollRef: { current: null }, }), })); assert.match(markup, /data-prompt-turn-id="turn-2"/); - assert.match(markup, /data-resident="false"/); - assert.match(markup, /aria-label="Load and jump to prompt: Prompt 2"/); + assert.doesNotMatch(markup, /data-resident/); + assert.match(markup, /aria-label="Jump to prompt: Prompt 2"/); + assert.doesNotMatch(markup, /Not currently loaded/); assert.doesNotMatch(markup, /aria-disabled="true"/); }); diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx index 4d50cf313d..2a26d78b30 100644 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ b/packages/ui/src/__tests__/transcript-history-notice.test.tsx @@ -25,8 +25,7 @@ import { TranscriptHistoryNotice } from '../chat-view.js'; function renderNotice(isPending: boolean): string { return renderToStaticMarkup( undefined} @@ -34,22 +33,23 @@ function renderNotice(isPending: boolean): string { ); } -test('explains a partial historical range as persistent accessible status', () => { +test('presents historical position as quiet persistent status', () => { const markup = renderNotice(false); assert.match(markup, /role="status"/); assert.match(markup, /aria-live="polite"/); assert.match(markup, /aria-atomic="true"/); - assert.match(markup, /Viewing an earlier part of this conversation/); - assert.match(markup, /Newer messages are still saved, but are not currently loaded\./); + assert.match(markup, /Viewing earlier messages/); assert.match(markup, /Return to latest/); + assert.doesNotMatch(markup, /saved|loaded/); + assert.doesNotMatch(markup, / { +test('keeps the position status visible while return-to-latest is pending', () => { const markup = renderNotice(true); - assert.match(markup, /Viewing an earlier part of this conversation/); - assert.match(markup, /Newer messages are still saved, but are not currently loaded\./); + assert.match(markup, /Viewing earlier messages/); + assert.doesNotMatch(markup, /saved|loaded/); assert.match(markup, /disabled/); }); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0cd838973c..2efc296d3a 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -41,7 +41,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import { isDeepResearchSession } from '@maka/core/explore-agent'; -import { Button, ButtonGroup, ChatMessageList, EmptyState, Spinner } from '@astryxdesign/core'; +import { Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text } from '@astryxdesign/core'; import { useChatLayoutContext } from '@astryxdesign/core/Chat'; import { useLayer } from '@astryxdesign/core/Layer'; import { materializeChat } from './materialize.js'; @@ -71,31 +71,30 @@ export interface LiveContentActivationSnapshot { export interface TranscriptHistoryNoticeProps { title: string; - description: string; actionLabel: string; isPending: boolean; onReturnToLatest(): Promise | void; } -/** Persistent explanation for a bounded range that omits newer durable messages. */ +/** Persistent navigation position with a direct path back to the transcript tail. */ export function TranscriptHistoryNotice({ title, - description, actionLabel, isPending, onReturnToLatest, }: TranscriptHistoryNoticeProps) { return ( -
-
- {title} - {description} -
+ {title}
+ ); } @@ -265,7 +264,6 @@ export function ChatView(props: { onLoadEarlierHistory?(): Promise | void; returnToLatest?: { title: string; - description: string; label: string; isPending: boolean; onClick(): Promise | void; @@ -670,7 +668,6 @@ export function ChatView(props: { {props.returnToLatest ? ( diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 39aac5c236..e8f2b94e15 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -420,8 +420,6 @@ export interface ConversationCopy { promptRailAriaLabel: string; emptyPrompt: string; jumpToPrompt: (preview: string) => string; - loadPrompt: (preview: string) => string; - unloadedPrompt: string; }; } @@ -545,7 +543,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, - listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, loadPrompt: (preview) => `加载并跳到提问:${preview}`, unloadedPrompt: '当前未加载,点击后载入', + listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, }, }, en: { @@ -693,7 +691,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, - listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, loadPrompt: (preview) => `Load and jump to prompt: ${preview}`, unloadedPrompt: 'Not currently loaded; select to load', + listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 5cabad2ee4..707a80340e 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -214,7 +214,6 @@ export interface PromptAnchorRailTurn { label: string; reply?: string; sequence?: number; - isResident: boolean; } export function mergePromptAnchorRailTurns( @@ -222,7 +221,7 @@ export function mergePromptAnchorRailTurns( index?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>, ): PromptAnchorRailTurn[] { if (!index || index.length === 0) { - return loadedTurns.map((turn) => ({ ...turn, isResident: true })); + return loadedTurns.map((turn) => ({ ...turn })); } const loadedByTurnId = new Map(loadedTurns.map((turn) => [turn.turnId, turn])); return index.map((landmark) => { @@ -234,7 +233,6 @@ export function mergePromptAnchorRailTurns( reply: '', }), sequence: landmark.sequence, - isResident: loaded !== undefined, }; }); } @@ -539,9 +537,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const isActive = turn.turnId === activeTurnIdRef.current; const preview = turn.label.trim() || copy.emptyPrompt; const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); - const actionLabel = turn.isResident - ? copy.jumpToPrompt(preview) - : copy.loadPrompt(preview); const proximity = hoveredIndex === null ? HOVER_FALLOFF_TICKS @@ -555,9 +550,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe content={ {preview} - {!turn.isResident ? ( - {copy.unloadedPrompt} - ) : replyPreview ? ( + {replyPreview ? ( {replyPreview} ) : null} @@ -567,10 +560,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe type="button" variant="ghost" size="sm" - label={actionLabel} + label={copy.jumpToPrompt(preview)} className="maka-prompt-rail-tick" data-prompt-turn-id={turn.turnId} - data-resident={turn.isResident ? 'true' : 'false'} data-active={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined} onClick={() => jumpTo(turn)} From 5e378168031dba489673df3282be84f7d1620e32 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:41:15 +0800 Subject: [PATCH 2/3] test(desktop): cover partial history presentation Generated-by: OpenAI Codex --- apps/desktop/e2e/fixtures.ts | 12 ++ .../e2e/partial-history-notice.spec.ts | 137 ++++++++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 13 ++ .../src/main/e2e-fixture/scenarios-chat.ts | 43 ++++++ .../src/main/e2e-fixture/seed-helpers.ts | 1 + packages/core/src/e2e-fixture.ts | 1 + 6 files changed, 207 insertions(+) create mode 100644 apps/desktop/e2e/partial-history-notice.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 16b077cdb5..cff13431e1 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -482,6 +482,7 @@ export const test = base.extend<{ parentRemovalWindow: Page; railRenderWindow: Page; promptRailWindow: Page; + partialHistoryWindow: Page; promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; @@ -588,6 +589,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', + showWindow: true, + }, use); + }, // The same transcript, scrolling the way the shipped app scrolls. Separate // from `promptRailWindow` because it is only the jump that needs a scroll // still in flight, and paying for one everywhere costs several seconds per 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..9b70c12b6a --- /dev/null +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -0,0 +1,137 @@ +/* + * 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 type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +const NOTICE = '.maka-transcript-history-controls'; + +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 ({ + partialHistoryWindow: page, +}) => { + await page.setViewportSize({ width: 1_400, height: 800 }); + await expect(page.locator(NOTICE)).toHaveCount(0); + + const firstPrompt = page.locator( + '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', + ); + await expect(firstPrompt).toBeVisible(); + await firstPrompt.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 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, + }; + }); + expect(railPresentation.residentAttributeCount).toBe(0); + expect(railPresentation.residentStyleRuleCount).toBe(0); + expect(railPresentation.neutralTickCount).toBeGreaterThan(1); + expect(railPresentation.neutralPaintCount).toBe(1); + + 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); + + await notice.getByRole('button', { name: '返回最新消息' }).click(); + await expect(notice).toHaveCount(0); + await expect( + page.locator('[data-turn-id="turn-partial-history-8"]'), + ).toBeVisible(); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index b8643cab17..dff4e6aab5 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -34,11 +34,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, @@ -59,6 +62,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', @@ -174,6 +178,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState // this window scrolls smoothly is a per-launch choice (`scrollMotion`), // because only the jump case needs it and it costs seconds of settling. 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': @@ -225,6 +231,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 420ee2e516..760d9d5887 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, + }); +} + +/** + * Eight turns whose durable transcript is well over the Desktop range budget. + * 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); + for (let index = 1; index <= 8; index += 1) { + const turnId = `turn-partial-history-${index}`; + const ts = now - (9 - 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 801267932c..a6020dc291 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 100-turn mounted-window bounds. */ export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 065a8187f6..b684297249 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' From 78e6073e4b72be90da10242922c12e9eeada6e9d Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:50:27 +0800 Subject: [PATCH 3/3] docs: refresh Astryx surface inventory --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 8be11cb333..42233ba255 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -196,7 +196,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, HStack, IconButton, Spinner, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, HStack, IconButton, Spinner, Token, Tooltip) | aligned | -| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, EmptyState, Spinner | aligned — uses Astryx (Button, EmptyState, Spinner) | aligned | +| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, EmptyState, HStack, Spinner, Text) | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw `