From ead67c8ef088fa74586f4e74fca1d95f5bfb8ec5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 10 Sep 2026 14:06:01 +0800 Subject: [PATCH 1/3] fix(ui): render message action rows with Astryx Icon and IconButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy / edit / regenerate / branch buttons under messages used lucide glyphs at a product-side 14px rung inside Astryx IconButton, then reset the button's border, radius, ink and pending/copied colours from styles.css, and expressed disabled and busy through aria-disabled + data attributes instead of the isDisabled / isLoading props. The assistant meta ("model · duration · cost") was an IconButton whose click did nothing, and the user row hid Astryx's timestamp separator by DOM position. Every glyph now goes through Astryx Icon at size sm (copy / check by semantic name, regenerate / branch / edit as SVG components), IconButton keeps its own geometry, colours and states, the meta is a text span in the same footer row, and both senders share one reveal rule keyed on the message container. The user row's time and delivery status move into the footer slot so no separator is drawn and no positional CSS is needed. Refs #5133 Generated-by: Claude Code --- .../src/renderer/app-shell-turn-view-model.ts | 10 ++- .../tools/side-chat/quote-companion-panel.tsx | 1 + .../src/renderer/locales/conversation-copy.ts | 8 +- .../src/renderer/turn-footer-actions.ts | 21 +---- .../chat-turn-answer-identity.test.tsx | 4 +- packages/ui/src/chat-turn.tsx | 80 +++++++++---------- packages/ui/src/chat-view.tsx | 1 + packages/ui/src/styles.css | 62 +++----------- 8 files changed, 69 insertions(+), 118 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell-turn-view-model.ts b/apps/desktop/src/renderer/app-shell-turn-view-model.ts index 7c8f9205766..ff2689e1f42 100644 --- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts +++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts @@ -54,6 +54,7 @@ export interface AppShellTurnPresentationDerivation { /** What one turn contributes to the presentation; cached against that turn. */ interface TurnPresentationEntry { footerActions: ReadonlyArray; + footerMeta?: string; lineageBadges?: TurnLineageBadge[]; failedReasonLabel?: string; failedSeverity?: FailedTurnSeverity; @@ -112,6 +113,7 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta const turnIds = new Set(turns.map((turn) => turn.turnId)); const existsTurn = (id: string) => turnIds.has(id); const footerActionsByTurn: Record> = {}; + const footerMetaByTurn: Record = {}; const failedReasonLabels: Record = {}; const failedSeverities: Record = {}; const failedExecutionStateLabels: Record = {}; @@ -154,6 +156,7 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta } footerActionsByTurn[turn.turnId] = entry.footerActions; + if (entry.footerMeta !== undefined) footerMetaByTurn[turn.turnId] = entry.footerMeta; if (entry.lineageBadges) lineageBadgesByTurn[turn.turnId] = entry.lineageBadges; if (entry.failedReasonLabel !== undefined) failedReasonLabels[turn.turnId] = entry.failedReasonLabel; if (entry.failedSeverity !== undefined) failedSeverities[turn.turnId] = entry.failedSeverity; @@ -169,6 +172,7 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta lastUiLocale = context.uiLocale; lastResult = { footerActionsByTurn, + footerMetaByTurn, failedReasonLabels, failedSeverities, failedExecutionStateLabels, @@ -207,10 +211,12 @@ function deriveTurnPresentationEntry(input: { ? { alreadyRegenerated: true } : {}), ...(pendingForTurn.size > 0 ? { pendingActions: pendingForTurn } : {}), - ...(metaSummary ? { metaSummary } : {}), }); - const entry: TurnPresentationEntry = { footerActions }; + const entry: TurnPresentationEntry = { + footerActions, + ...(metaSummary ? { footerMeta: metaSummary } : {}), + }; if (turn.status === 'failed' && (turn.failureMessage || !isSandboxOnlyToolFailure(turn))) { entry.failedReasonLabel = describeTurnErrorClass(turn.errorClass, uiLocale); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 103c8098ffa..5d0c7a2aa91 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -290,6 +290,7 @@ export function QuoteCompanionPanel(props: { }).filter((action) => action.id !== 'branch'), ]), ), + footerMetaByTurn: {}, failedReasonLabels: {}, failedSeverities: {}, failedExecutionStateLabels: {}, diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index b83cddc75b8..b867275ebd2 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -69,7 +69,7 @@ export interface DesktopConversationCopy { configurationReason: Record; }; footer: { - labels: Record<'regenerate' | 'branch' | 'copy' | 'info', string>; + labels: Record<'regenerate' | 'branch' | 'copy', string>; pending: string; regenerateRunning: string; regenerateAgain: string; @@ -464,7 +464,7 @@ const COPY = { provider_retired: '当前任务绑定的连接,其登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接后新建任务。', }, }, - footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制', info: '详情' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', requestRegenerate: '请求所有者批准重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' }, + footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', requestRegenerate: '请求所有者批准重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' }, lineage: { regeneratedFrom: '重新生成自旧回答', regeneratedFromTooltip: '这是重新生成的并行回答,点击查看被保留的旧回答', regeneratedTo: '已重新生成 → 新回答', regeneratedToTooltip: '点击跳转到重新生成的新回答' }, workbar: { ariaLabel: '任务工作栏', @@ -703,7 +703,7 @@ const COPY = { provider_retired: '目前任務繫結的連線,其登入方式已從 Maka 移除,無法用於傳送。請到 設定 · 模型 改用其他連線後建立任務。', }, }, - footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '複製', info: '詳情' }, pending: '正在處理…', regenerateRunning: '目前回答仍在進行中,結束後再重新生成', regenerateAgain: '已重新生成過,再次點選將建立新的並行回答', requestRegenerate: '請求擁有者核准重新生成本輪回答', regenerate: '讓模型重新生成本輪迴答', branchRunning: '目前回答仍在進行中,結束後再分支', branchAborted: '從中斷前的上下文分支出新任務', branch: '基於此回答的上下文分支出新任務', copy: '複製回答到剪貼簿', copyEmpty: '此回答尚無可複製的內容' }, + footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '複製' }, pending: '正在處理…', regenerateRunning: '目前回答仍在進行中,結束後再重新生成', regenerateAgain: '已重新生成過,再次點選將建立新的並行回答', requestRegenerate: '請求擁有者核准重新生成本輪回答', regenerate: '讓模型重新生成本輪迴答', branchRunning: '目前回答仍在進行中,結束後再分支', branchAborted: '從中斷前的上下文分支出新任務', branch: '基於此回答的上下文分支出新任務', copy: '複製回答到剪貼簿', copyEmpty: '此回答尚無可複製的內容' }, lineage: { regeneratedFrom: '重新生成自舊回答', regeneratedFromTooltip: '這是重新生成的並行回答,點選檢視被保留的舊回答', regeneratedTo: '已重新生成 → 新回答', regeneratedToTooltip: '點選跳轉到重新生成的新回答' }, workbar: { ariaLabel: '任務工作欄', @@ -933,7 +933,7 @@ const COPY = { provider_retired: 'The sign-in this task\u2019s connection uses was removed from Maka, so it cannot send. Switch to another connection in Settings · Models, then start a new task.', }, }, - footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy', info: 'Details' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', requestRegenerate: 'Ask the Owner to approve regenerating this response', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, + footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', requestRegenerate: 'Ask the Owner to approve regenerating this response', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, lineage: { regeneratedFrom: 'Regenerated from previous response', regeneratedFromTooltip: 'This is a parallel regenerated response; click to view the retained previous response', regeneratedTo: 'Regenerated → New response', regeneratedToTooltip: 'Jump to the regenerated response' }, workbar: { ariaLabel: 'Task workbar', diff --git a/apps/desktop/src/renderer/turn-footer-actions.ts b/apps/desktop/src/renderer/turn-footer-actions.ts index 02f874b8e56..f194de26b65 100644 --- a/apps/desktop/src/renderer/turn-footer-actions.ts +++ b/apps/desktop/src/renderer/turn-footer-actions.ts @@ -49,7 +49,7 @@ import type { TurnStatus } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -export type TurnFooterActionId = 'regenerate' | 'branch' | 'copy' | 'info'; +export type TurnFooterActionId = 'regenerate' | 'branch' | 'copy'; export interface TurnFooterAction { id: TurnFooterActionId; @@ -83,14 +83,6 @@ export interface TurnFooterContext { * understands a parallel answer already exists. */ alreadyRegenerated?: boolean; - /** - * Optional one-line summary of the turn's meta (model · duration · - * cost). When present, the footer renders an `info` action - * whose tooltip carries this text — the single home for turn meta - * now that the top summary row is gone (#546). Absent on turns with - * no meta (fake backend, not-yet-streamed). - */ - metaSummary?: string; /** * Per @kenji review: prevent double-click duplicate sibling turns. * The renderer marks an action `pending` from click time until @@ -112,7 +104,7 @@ export interface TurnFooterContext { * optimistic guesses. */ export function deriveTurnFooterActions(input: TurnFooterContext): TurnFooterAction[] { - const { status, hasContent, alreadyRegenerated, pendingActions, metaSummary } = input; + const { status, hasContent, alreadyRegenerated, pendingActions } = input; const copyText = getDesktopConversationCopy(input.locale).footer; const actionLabel = copyText.labels; const isPending = (id: TurnFooterActionId) => pendingActions?.has(id) ?? false; @@ -151,12 +143,5 @@ export function deriveTurnFooterActions(input: TurnFooterContext): TurnFooterAct tooltip: hasContent ? copyText.copy : copyText.copyEmpty, }; - // info is informational, not an operation: no pending state, always - // enabled, and its tooltip carries the turn meta summary. Rendered - // only when there is meta to show (#546). - const info: TurnFooterAction | undefined = metaSummary - ? { id: 'info', label: actionLabel.info, enabled: true, tooltip: metaSummary } - : undefined; - - return [regenerate, branch, copy, ...(info ? [info] : [])]; + return [regenerate, branch, copy]; } diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index fd933d29b03..cb96e0f2bc8 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -441,11 +441,11 @@ test('footer copy preserves raw text, blocks overlapping writes and resets succe assert.equal(writeText.mock.callCount(), 1); assert.equal(writeText.mock.calls[0]?.arguments[0], text); assert.equal(button.getAttribute('data-copy-feedback'), 'pending'); - assert.equal(button.getAttribute('data-pending'), 'true'); + assert.equal(button.getAttribute('aria-busy'), 'true'); await act(async () => pending.resolve()); assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); - assert.equal(button.hasAttribute('data-pending'), false); + assert.notEqual(button.getAttribute('aria-busy'), 'true'); await act(async () => t.mock.timers.tick(1399)); assert.equal(button.getAttribute('data-copy-feedback'), 'copied'); await act(async () => t.mock.timers.tick(1)); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 97b9fc14992..7aced7bc80e 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -18,7 +18,7 @@ */ import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; -import { ICON_SIZE, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; +import { ICON_SIZE, Ban, GitBranch, Pencil, RefreshCcw, Timer } from './icons.js'; import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { formatTurnDuration, turnAbortStatusLabel } from './chat-display-helpers.js'; @@ -44,6 +44,7 @@ import { } from '@astryxdesign/core'; import { ChatReasoning } from './astryx-chat-reasoning.js'; import { Tooltip } from '@astryxdesign/core/Tooltip'; +import { Icon } from '@astryxdesign/core/Icon'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { type AttachmentRef, @@ -170,23 +171,25 @@ const UserMessageBody = memo(function UserMessageBody(props: { const editActionLabel = props.editDisabled ? (props.editDisabledReason ?? copyText.editMessageDisabledRunning) : copyText.editMessage; + // Time and delivery status ride in the footer slot beside the actions rather + // than in Astryx's `timestamp` slot: that slot draws a `·` before the footer, + // and a separator between always-visible text and hover-revealed buttons + // reads as a stray mark at rest. const userMetadata = ( - {props.delivery.deliveryStatus} - - ) : props.ts !== undefined ? ( - /* `value` takes ms directly: Timestamp's own parseValue reads - anything past 1e12 as milliseconds (2001-09-09 onward), and a - chat message never predates that. */ - () - ) : undefined - } footer={ <> + {props.delivery?.deliveryStatus ? ( + + {props.delivery.deliveryStatus} + + ) : props.ts !== undefined ? ( + /* `value` takes ms directly: Timestamp's own parseValue reads + anything past 1e12 as milliseconds (2001-09-09 onward), and a + chat message never predates that. */ + + ) : null} {props.delivery?.deliveryActions?.map((action) => ( ))} @@ -202,17 +205,14 @@ const UserMessageBody = memo(function UserMessageBody(props: { accessibleActionContext(props.text, props.ts, locale), )} tooltip={editActionLabel} - icon={