diff --git a/apps/desktop/src/renderer/styles/chat-detail.css b/apps/desktop/src/renderer/styles/chat-detail.css index ec108d60b0..4e59976aca 100644 --- a/apps/desktop/src/renderer/styles/chat-detail.css +++ b/apps/desktop/src/renderer/styles/chat-detail.css @@ -35,10 +35,10 @@ background: oklch(from var(--accent) l c h / 0.045); } -/* Turn aborted marker / lineage rows + badges / footer + footer actions - * retired to the `@maka/ui` Marker chat primitive - * (`aborted` / `lineage-row` / `lineage-row-reverse` / `lineage-badge` / - * `footer` / `footer-action`), issue #332 PR2 — packages/ui/src/primitives/chat.tsx. +/* Lineage rows + badges / footer + footer actions retired to the `@maka/ui` + * Marker chat primitive (`lineage-row` / `lineage-row-reverse` / + * `lineage-badge` / `footer` / `footer-action`), issue #332 PR2 — + * packages/ui/src/primitives/chat.tsx. * The failed-turn banner is an Astryx `Banner`, not a Marker variant. * The measure-column geometry the `tool-output.css` re-anchor used to add * to the summary / lineage rows / footer is folded into those Marker 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 ce6052f940..f5290c968a 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -106,6 +106,34 @@ const RUNNING_TOOL: TurnTimelineItem = { items: [{ toolUseId: 'tool-1', toolName: 'read', status: 'running', args: {} }], }; +test('renders an aborted turn outcome as an inline system status notice', async () => { + const { container, root } = domRoot(); + await renderTurn(root, { + ...turnWith([{ ...ANSWER, live: false }]), + status: 'aborted', + abortSource: 'renderer.stop_button', + }); + + const outcome = container.querySelector('.astryx-chat-system-message[role="status"]'); + assert.ok(outcome, 'the aborted outcome is announced through the Chat status-notice primitive'); + assert.equal(outcome.getAttribute('data-variant'), 'default'); + assert.equal(outcome.textContent, 'Interrupted \u00b7 Stop button'); +}); + +test('places the aborted turn outcome after its timeline content', async () => { + const { container, root } = domRoot(); + await renderTurn(root, { + ...turnWith([{ ...ANSWER, live: false }]), + status: 'aborted', + }); + + const answer = container.querySelector('.maka-chat-message-bubble-assistant'); + const assistantMessage = container.querySelector('.maka-assistant-answer'); + const outcome = container.querySelector('.astryx-chat-system-message[role="status"]'); + assert.ok(answer && assistantMessage && outcome); + assert.equal(assistantMessage.nextElementSibling?.isSameNode(outcome), true); +}); + /** * Keying the answer by its first timeline entry made the key change whenever * that entry did, so React unmounted the answer and mounted a copy — taking diff --git a/packages/ui/src/chat-display-helpers.ts b/packages/ui/src/chat-display-helpers.ts index 555cb4101a..5831a26ff5 100644 --- a/packages/ui/src/chat-display-helpers.ts +++ b/packages/ui/src/chat-display-helpers.ts @@ -84,7 +84,7 @@ export function formatTurnDuration(ms: number): string { return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`; } -export function turnAbortMarkerLabel(abortSource: string | undefined, locale: UiLocale): string { +export function turnAbortStatusLabel(abortSource: string | undefined, locale: UiLocale): string { const copy = getConversationCopy(locale).messages; switch (abortSource) { case 'renderer.stop_button': return copy.abortedByStop; diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index c2559e6241..a3fd9fa896 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { useMountedRef } from './use-mounted-ref.js'; import { ICON_SIZE, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; @@ -25,7 +25,7 @@ import { Markdown } from './markdown.js'; import { formatAbsoluteTimestamp, formatTurnDuration, - turnAbortMarkerLabel, + turnAbortStatusLabel, } from './chat-display-helpers.js'; import { isTimeDrivenMotionEnabled } from './streaming-presentation.js'; import { computerRunningLabel } from './tool-activity/computer-action-label.js'; @@ -645,27 +645,21 @@ export const TurnView = memo(function TurnView(props: { ); } const ownsTurnChrome = segmentIndex === conversationSegments.length - 1; + // Disjoint namespaces: a steering id is any string, so a bare + // sentinel could collide with a real one. + const assistantKey = + segment.repliesTo === undefined + ? 'assistant-opening' + : `assistant-after-${segment.repliesTo}`; return ( - + +
- {ownsTurnChrome && turn.status === 'aborted' && ( - - - )} {/* The turn timeline is the rendering source of truth (materialize.ts): each step's 深度思考 disclosure, answer bubble, and Astryx tool group in the order the model produced them. @@ -699,12 +693,9 @@ export const TurnView = memo(function TurnView(props: { /> ), )} - {/* A failed turn's banner states the OUTCOME, so it belongs after - the work it is the outcome of. It used to render above the - timeline, where it read as a header on reasoning and tool - calls that had in fact all succeeded. - - `description` carries the parked-resume diagnostic when there + {/* A failed turn's banner states the OUTCOME of the turn, so it + belongs after the work it is the outcome of. `description` + carries the parked-resume diagnostic when there is one — it explains why the button did nothing, which outranks execution state on the one turn that can have both. */} {ownsTurnChrome && turn.status === 'failed' && props.failedReasonLabel && ( @@ -799,7 +790,16 @@ export const TurnView = memo(function TurnView(props: { /> ) ))} - + + {/* An abort is a short, settled status change rather than sender + content or a recovery error. Keep Astryx's system notice as a + sibling of the assistant message, after the work it closes. */} + {ownsTurnChrome && turn.status === 'aborted' && ( + + )} + ); })} diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 73e32dab47..e8f2b94e15 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -499,7 +499,7 @@ const CONVERSATION_COPY = { messages: { you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, 'zh')}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, - thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中断)', abortedByStop: '(已中断 · 由停止按钮触发)', + thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', systemNotes: { contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。', contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。', @@ -647,7 +647,7 @@ const CONVERSATION_COPY = { messages: { you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, 'en')} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, - thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: '(Interrupted)', abortedByStop: '(Interrupted · Stop button)', + thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button', systemNotes: { contextCompacted: 'Context compacted to keep this session within the model window.', contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.', diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx index f14a0b2230..87e237772c 100644 --- a/packages/ui/src/primitives/chat.tsx +++ b/packages/ui/src/primitives/chat.tsx @@ -23,10 +23,10 @@ import type React from "react"; import { cn } from "../utils.js"; /** - * `Marker` — the per-turn status / lineage / footer chrome (issue #332, PR2). + * `Marker` — the per-turn lineage / footer chrome (issue #332, PR2). * - * Retires the bespoke `.maka-turn-summary*`, `.maka-turn-aborted-marker`, - * `.maka-turn-lineage-*`, and `.maka-turn-footer*` shell + * Retires the bespoke `.maka-turn-summary*`, `.maka-turn-lineage-*`, and + * `.maka-turn-footer*` shell * CSS (spread across `maka-tokens.css`, `styles/settings/models.css`, and the * re-anchored measure-column block in `styles/tool-output.css`), moving each * onto package-owned semantic classes. @@ -46,7 +46,6 @@ import { cn } from "../utils.js"; * */ export type MarkerVariant = - | "aborted" | "host-origin" | "lineage-row" | "lineage-row-reverse" @@ -55,7 +54,6 @@ export type MarkerVariant = | "footer-action"; const MARKER_CLASSES: Record = { - aborted: "maka-turn-aborted-marker", "host-origin": "maka-turn-host-origin", "lineage-row": "maka-turn-lineage-row", "lineage-row-reverse": "maka-turn-lineage-row maka-turn-lineage-row-reverse", diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 3bfe551dfd..be419c8c33 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -752,7 +752,6 @@ .maka-sandbox-blocked-copy[data-copy-feedback="failed"] { border-color: oklch(from var(--destructive) l c h / 0.35); color: var(--destructive); } .maka-turn-status-icon { flex: 0 0 auto; } -.maka-turn-aborted-marker, .maka-turn-host-origin { font: var(--maka-text-supporting); display: inline-flex; @@ -762,17 +761,6 @@ border-radius: var(--radius-control); } -.maka-turn-aborted-marker { - gap: var(--space-1); - margin-block: var(--space-0-5) var(--space-1); - padding: var(--space-0-5) var(--space-1-5); - background: var(--foreground-5); - color: var(--foreground-secondary); - font-style: italic; -} - -.maka-turn-aborted-marker em { font-style: italic; } - .maka-turn-host-origin { align-self: flex-end; gap: var(--space-1);