diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d24cb05c29d..a86aa1b1d65 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -706,8 +706,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 11, - "nonTriviaTokens": 1395 + "importSpecifiers": 10, + "nonTriviaTokens": 1273 }, "src/renderer/app-shell.tsx": { "importDeclarations": 70, 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..7248e7cd859 100644 --- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts +++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts @@ -22,7 +22,6 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { deriveTurnLineageMap, finalAssistantReplyText, - formatTurnDuration, isSandboxDeniedTool, type TurnFooterActionMeta, type TurnLineageBadge, @@ -189,14 +188,6 @@ function deriveTurnPresentationEntry(input: { uiLocale: UiLocale; }): TurnPresentationEntry { const { turn, lineageEntry, pendingForTurn, uiLocale } = input; - const metaParts: string[] = []; - if (turn.modelId) metaParts.push(turn.modelId); - // Below a second there is nothing to report: a turn's duration counts whole - // seconds, so a 300ms turn would read「0s」— a number that says less than no - // number at all. - if (turn.durationMs && turn.durationMs >= 1_000) metaParts.push(formatTurnDuration(turn.durationMs)); - if (turn.tokens?.costUsd && turn.tokens.costUsd > 0) metaParts.push(`$${turn.tokens.costUsd.toFixed(4)}`); - const metaSummary = metaParts.length > 0 ? metaParts.join(' · ') : undefined; const footerActions = deriveTurnFooterActions({ status: turn.status, locale: uiLocale, @@ -207,7 +198,6 @@ function deriveTurnPresentationEntry(input: { ? { alreadyRegenerated: true } : {}), ...(pendingForTurn.size > 0 ? { pendingActions: pendingForTurn } : {}), - ...(metaSummary ? { metaSummary } : {}), }); const entry: TurnPresentationEntry = { footerActions }; 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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index f2ff06b4351..cc1aaa207dc 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -241,7 +241,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned | | `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem) | 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, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | +| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | aligned | | `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | 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..1a70f9d4611 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={