refactor(web): clarify chat activity timeline

This commit is contained in:
xiaoche-hub
2026-07-16 19:44:48 +08:00
parent fa876bf452
commit ebb86284ef
6 changed files with 290 additions and 155 deletions
+146 -119
View File
@@ -71,7 +71,7 @@ import {
type PluginFolderCandidate,
} from "./design-files/pluginFolders";
import type { PluginFolderAgentAction } from "./design-files/pluginFolderActions";
import { Icon } from "./Icon";
import { Icon, type IconName } from "./Icon";
import { NextStepActions, type NextStepActionsVariant } from "./NextStepActions";
import type { DesignToolboxActionId } from "../runtime/design-toolbox";
import { copyToClipboard } from "../lib/copy-to-clipboard";
@@ -868,6 +868,26 @@ function AssistantMessageImpl({
<span className="role-name">{roleName}</span>
</div>
<div className="assistant-flow">
{taskActivity ? (
<TaskActivityCard
entries={taskActivity.entries}
trailingThinking={taskActivity.trailingThinking}
runStreaming={streaming}
runSucceeded={runSucceeded}
runFailed={
!streaming &&
(message.runStatus === "failed" ||
message.runStatus === "canceled" ||
hasResultDeliveryFailure)
}
startedAt={message.startedAt}
endedAt={message.endedAt}
durationMs={usage?.durationMs}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
onThinkingLinkClick={thinkingLinkClick}
/>
) : null}
{contentBlocks.map((b, i) => {
if (b.kind === "text")
return (
@@ -947,16 +967,6 @@ function AssistantMessageImpl({
}
return null;
})}
{taskActivity ? (
<TaskActivityCard
items={taskActivity.items}
liveTools={taskActivity.liveTools}
runStreaming={streaming}
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
) : null}
{brandBrowserAssistFallbackCard ? (
<OdCardView
card={brandBrowserAssistFallbackCard}
@@ -1054,6 +1064,7 @@ function AssistantMessageImpl({
forking,
forceVisible: true,
isLast: !!isLast,
hideRunStatus: taskActivity !== null,
}}
/>
) : (
@@ -1070,6 +1081,7 @@ function AssistantMessageImpl({
onFork={canFork ? onForkFromMessage : undefined}
forking={forking}
isLast={!!isLast}
hideRunStatus={taskActivity !== null}
/>
)}
</div>
@@ -1514,6 +1526,10 @@ interface AssistantFooterProps {
// The most recent assistant reply keeps its footer permanently visible
// (not hover-gated), matching Lobe Chat's persistent last-message footer.
isLast?: boolean;
// When the turn has an execution disclosure, its run state and elapsed time
// live at the top of the answer. The footer keeps only secondary stats and
// actions so the same Done/Working state is not repeated at the bottom.
hideRunStatus?: boolean;
}
function AssistantFooter({
@@ -1531,6 +1547,7 @@ function AssistantFooter({
feedbackControls,
forceVisible = false,
isLast = false,
hideRunStatus = false,
}: AssistantFooterProps) {
const t = useT();
const elapsed = useLiveElapsed(streaming, startedAt, endedAt, usage?.durationMs);
@@ -1540,12 +1557,18 @@ function AssistantFooter({
usage.costUsd > 0
? usage.costUsd.toFixed(4)
: "";
const costLabel = formattedCost && formattedCost !== "0.0000" ? ` · $${formattedCost}` : "";
const statParts = [
hideRunStatus ? "" : elapsed,
usage?.outputTokens != null
? t("assistant.outTokens", { n: usage.outputTokens })
: "",
formattedCost && formattedCost !== "0.0000" ? `$${formattedCost}` : "",
].filter(Boolean);
const statsLabel = statParts.join(" · ");
if (
!forceVisible &&
!streaming &&
!elapsed &&
!usage &&
!statsLabel &&
!hasUnfinishedTodos &&
!hasEmptyResponse &&
!copyMarkdown &&
@@ -1559,27 +1582,25 @@ function AssistantFooter({
data-streaming={streaming ? "true" : "false"}
data-last={isLast ? "true" : "false"}
>
<span className="dot" data-active={streaming ? "true" : "false"} />
<span className={`assistant-label${streaming && preparing ? " shimmer-text shimmer-prepare" : ""}`}>
{streaming
? preparing
? preparingStatus === "thinking"
? t("assistant.statusThinking")
: t("assistant.statusPreparing")
: t("assistant.workingLabel")
: hasEmptyResponse
? t("assistant.emptyResponseLabel")
: hasUnfinishedTodos
? t("assistant.unfinishedLabel")
: t("assistant.doneLabel")}
</span>
<span className="assistant-stats">
{elapsed}
{usage?.outputTokens != null
? ` · ${t("assistant.outTokens", { n: usage.outputTokens })}`
: ""}
{costLabel}
</span>
{!hideRunStatus ? (
<>
<span className="dot" data-active={streaming ? "true" : "false"} />
<span className={`assistant-label${streaming && preparing ? " shimmer-text shimmer-prepare" : ""}`}>
{streaming
? preparing
? preparingStatus === "thinking"
? t("assistant.statusThinking")
: t("assistant.statusPreparing")
: t("assistant.workingLabel")
: hasEmptyResponse
? t("assistant.emptyResponseLabel")
: hasUnfinishedTodos
? t("assistant.unfinishedLabel")
: t("assistant.doneLabel")}
</span>
</>
) : null}
{statsLabel ? <span className="assistant-stats">{statsLabel}</span> : null}
{copyMarkdown || onFork || feedbackControls ? (
<span className="assistant-footer-controls">
{copyMarkdown ? <AssistantMarkdownCopyButton markdown={copyMarkdown} /> : null}
@@ -2625,6 +2646,7 @@ function ProseBlock({
})}
{live ? (
<StreamingCodeCard
icon="file-code"
titleLabel={t("tool.write")}
metaLabel={live.title || live.identifier || undefined}
code={live.content}
@@ -3461,10 +3483,12 @@ function extractStreamingJsonString(raw: string, field: string): string | null {
// normal card once the write/artifact completes. Shared by the tool-call
// path (LiveCodeBox) and the streaming-artifact path (ProseBlock).
function StreamingCodeCard({
icon,
titleLabel,
metaLabel,
code,
}: {
icon: IconName;
titleLabel: string;
metaLabel?: string;
code: string;
@@ -3478,8 +3502,8 @@ function StreamingCodeCard({
return (
<div className="op-card op-file live-code-box">
<div className="op-card-head live-code-head">
<span className="action-card-status op-status-running" aria-hidden>
<Icon name="spinner" size={14} />
<span className="op-status op-status-category op-status-running" aria-hidden>
<Icon name={icon} size={14} />
</span>
<span className="op-title shimmer-text">{titleLabel}</span>
{metaLabel ? <span className="op-meta">{metaLabel}</span> : null}
@@ -3514,6 +3538,7 @@ function LiveCodeBox({ name, raw }: { name: string; raw: string }) {
const isEdit = /edit/i.test(name);
return (
<StreamingCodeCard
icon={isEdit ? "pencil" : "file-code"}
titleLabel={isEdit ? t("tool.edit") : t("tool.write")}
metaLabel={baseName || undefined}
code={code}
@@ -3619,43 +3644,61 @@ function ToolGroupCard({
}
/**
* One compact, per-turn audit trail for every non-progress tool. The default
* view intentionally says what happened without making individual tool cards
* compete with the answer or the result card; opening it restores the full
* chronological evidence, including code that was still streaming.
* One compact, per-turn audit trail for thinking and every non-progress tool.
* The default view keeps the overall run state above the answer; opening it
* restores the chronological evidence with a semantic icon for each activity.
*/
function TaskActivityCard({
items,
liveTools,
entries,
trailingThinking,
runStreaming,
runSucceeded,
runFailed,
startedAt,
endedAt,
durationMs,
projectFileNames,
onRequestOpenFile,
onThinkingLinkClick,
}: {
items: ToolItem[];
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
entries: TaskActivityEntry[];
trailingThinking: boolean;
runStreaming: boolean;
runSucceeded: boolean;
runFailed: boolean;
startedAt: number | undefined;
endedAt: number | undefined;
durationMs: number | undefined;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
onThinkingLinkClick?: MarkdownLinkClickHandler;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const settledItems = dedupeSnapshotToolRetries(items);
const running =
runStreaming &&
(liveTools.length > 0 || settledItems.some((item) => !item.result));
const hasError = settledItems.some(
(item) => item.result?.isError || (!item.result && !runStreaming && !runSucceeded),
const toolItems = entries
.filter((entry): entry is Extract<TaskActivityEntry, { kind: "tool" }> => entry.kind === "tool")
.map((entry) => entry.item);
const settledItems = dedupeSnapshotToolRetries(toolItems);
const visibleTools = new Set(settledItems);
const visibleEntries = entries.filter(
(entry) => entry.kind !== "tool" || visibleTools.has(entry.item),
);
const summary =
settledItems.length > 0
? summarizeTaskActivity(settledItems, t, running, runSucceeded)
: t("assistant.verbRunning");
const count = settledItems.length + liveTools.length;
const running = runStreaming;
const hasError =
!runStreaming &&
(runFailed ||
settledItems.some(
(item) => item.result?.isError || (!item.result && !runSucceeded),
));
const stateLabel = running
? t("assistant.workingLabel")
: hasError
? t("tool.error")
: t("assistant.doneLabel");
const elapsed = useLiveElapsed(runStreaming, startedAt, endedAt, durationMs);
return (
<div className="action-card task-activity">
<div className={`action-card task-activity${open ? " is-open" : ""}`}>
<button
type="button"
className={`action-card-toggle ${running ? "running" : ""}`}
@@ -3663,45 +3706,41 @@ function TaskActivityCard({
aria-expanded={open}
data-testid="task-activity-toggle"
>
<span
className={`action-card-status ${
running ? "op-status-running" : hasError ? "op-status-error" : "op-status-ok"
}`}
aria-hidden
>
{running ? (
<Icon name="spinner" size={14} />
) : hasError ? (
<Icon name="close" size={14} />
) : (
<Icon name="check" size={14} />
)}
</span>
<span className={`summary${running ? " shimmer-text" : ""}`}>{summary}</span>
<span className="task-activity-count" aria-hidden>
{count}
</span>
<span className={`summary${running ? " shimmer-text" : ""}`}>{stateLabel}</span>
{elapsed ? <span className="task-activity-elapsed">{elapsed}</span> : null}
<span className="chev" aria-hidden>
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
<Icon name="chevron-down" size={13} />
</span>
</button>
<div className={`accordion-collapsible${open ? " open" : ""}`}>
<div className="accordion-collapsible-inner">
<div className="action-card-body">
{settledItems.map((item, index) => (
<ToolCard
key={item.use.id || index}
use={item.use}
result={item.result}
runStreaming={runStreaming}
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
))}
{liveTools.map((tool) => (
<LiveCodeBox key={tool.id} name={tool.name} raw={tool.raw} />
))}
{visibleEntries.map((entry, index) => {
if (entry.kind === "thinking") {
return (
<ThinkingBlock
key={`thinking-${index}`}
text={entry.text}
streaming={runStreaming && trailingThinking && index === visibleEntries.length - 1}
onLinkClick={onThinkingLinkClick}
/>
);
}
if (entry.kind === "live-tool") {
return <LiveCodeBox key={entry.id} name={entry.name} raw={entry.raw} />;
}
return (
<ToolCard
key={entry.item.use.id || index}
use={entry.item.use}
result={entry.item.result}
runStreaming={runStreaming}
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
);
})}
</div>
</div>
</div>
@@ -3730,28 +3769,6 @@ function summarizeGroup(
return { label: tail ? `${head}, ${tail}` : head, icon };
}
function summarizeTaskActivity(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
running: boolean,
runSucceeded: boolean,
): string {
const familyCounts = new Map<string, number>();
for (const item of items) {
const family = toolFamily(item.use.name);
familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
}
const families = [...familyCounts.entries()];
const labels = families.slice(0, 3).map(([family, count]) => countLabel(family, count, t));
if (families.length > labels.length) labels.push(`+${families.length - labels.length}`);
const state = lastStateLabel(
items.map((item) => verbForState(item, t, running, runSucceeded)),
t,
);
return [...labels, ...(state ? [state] : [])].join(" · ");
}
function toolFamily(name: string): string {
if (name === "Edit" || name === "str_replace_edit") return "edit";
if (name === "Write" || name === "write" || name === "create_file") return "write";
@@ -3836,9 +3853,14 @@ type Block =
}
| { kind: "status"; label: string; detail?: string | undefined };
type TaskActivityEntry =
| Extract<Block, { kind: "thinking" }>
| Extract<Block, { kind: "live-tool" }>
| { kind: "tool"; item: ToolItem };
type TaskActivity = {
items: ToolItem[];
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
entries: TaskActivityEntry[];
trailingThinking: boolean;
};
function splitTaskActivity(blocks: Block[]): {
@@ -3846,12 +3868,15 @@ function splitTaskActivity(blocks: Block[]): {
taskActivity: TaskActivity | null;
} {
const contentBlocks: Block[] = [];
const items: ToolItem[] = [];
const liveTools: TaskActivity["liveTools"] = [];
const entries: TaskActivityEntry[] = [];
for (const block of blocks) {
if (block.kind === "thinking") {
entries.push(block);
continue;
}
if (block.kind === "live-tool") {
liveTools.push(block);
entries.push(block);
continue;
}
if (block.kind === "tool-group") {
@@ -3861,7 +3886,7 @@ function splitTaskActivity(blocks: Block[]): {
if (block.items.every((item) => isTodoWriteToolName(item.use.name))) {
contentBlocks.push(block);
} else {
items.push(...block.items);
entries.push(...block.items.map((item) => ({ kind: "tool" as const, item })));
}
continue;
}
@@ -3870,7 +3895,9 @@ function splitTaskActivity(blocks: Block[]): {
return {
contentBlocks,
taskActivity: items.length > 0 || liveTools.length > 0 ? { items, liveTools } : null,
taskActivity: entries.length > 0
? { entries, trailingThinking: blocks.at(-1)?.kind === "thinking" }
: null,
};
}
+32 -15
View File
@@ -13,7 +13,7 @@ import { useT } from '../i18n';
import { isTodoWriteToolName, parseTodoWriteInput } from '../runtime/todos';
import { getToolRenderer, toRenderProps } from '../runtime/tool-renderers';
import type { AgentEvent } from '../types';
import { Icon } from './Icon';
import { Icon, type IconName } from './Icon';
interface Props {
use: Extract<AgentEvent, { kind: 'tool_use' }>;
@@ -177,7 +177,7 @@ function LegacyAskUserQuestionCard({
return (
<div className="op-card op-generic">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="help-circle" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{title}</span>
{summary ? <span className="op-meta">{truncate(summary, 240)}</span> : null}
</div>
@@ -328,7 +328,7 @@ function FileWriteCard({
return (
<div className="op-card op-file">
<button type="button" className="op-card-head" onClick={() => setOpen((o) => !o)}>
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="file-code" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className={`op-title${isRunning ? ' shimmer-text' : ''}`}>{t('tool.write')}</span>
<span className="op-meta">{baseName}{lines !== null ? ` · ${t('tool.lines', { n: lines })}` : ''}</span>
<span className="op-expand-chev" aria-hidden>
@@ -378,7 +378,7 @@ function FileEditCard({
return (
<div className="op-card op-file">
<button type="button" className="op-card-head" onClick={() => setOpen((o) => !o)}>
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="pencil" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className={`op-title${isRunning ? ' shimmer-text' : ''}`}>{t('tool.edit')}</span>
<span className="op-meta">{baseName} · {editCount} {editCount === 1 ? t('tool.changeSingular') : t('tool.changePlural')}</span>
<span className="op-expand-chev" aria-hidden>
@@ -420,7 +420,7 @@ function FileReadCard({
return (
<div className="op-card op-file">
<button type="button" className="op-card-head" onClick={() => setOpen((o) => !o)}>
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="eye" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className={`op-title${isRunning ? ' shimmer-text' : ''}`}>{t('tool.read')}</span>
<span className="op-meta">{baseName}</span>
<span className="op-expand-chev" aria-hidden>
@@ -450,7 +450,7 @@ function BashCard({ input, result, runStreaming, runSucceeded }: { input: unknow
return (
<div className="op-card op-bash">
<button type="button" className="op-card-head" onClick={() => setOpen((o) => !o)}>
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="terminal" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className={`op-title${isRunning ? ' shimmer-text' : ''}`}>{t('tool.bash')}</span>
{desc ? <span className="op-meta op-desc">{desc}</span> : null}
<span className="op-expand-chev" aria-hidden>
@@ -477,7 +477,7 @@ function GlobCard({ input, result, runStreaming, runSucceeded }: { input: unknow
return (
<div className="op-card op-search">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="folder" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{t('tool.glob')}</span>
<span className="op-meta">{obj.pattern ?? '*'}{obj.path ? ` in ${obj.path}` : ''}</span>
</div>
@@ -491,7 +491,7 @@ function GrepCard({ input, result, runStreaming, runSucceeded }: { input: unknow
return (
<div className="op-card op-search">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="search" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{t('tool.grep')}</span>
<span className="op-meta">{obj.pattern ?? ''}{obj.path ? ` in ${obj.path}` : ''}</span>
</div>
@@ -505,7 +505,7 @@ function WebFetchCard({ input, result, runStreaming, runSucceeded }: { input: un
return (
<div className="op-card op-web">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="globe" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{t('tool.fetch')}</span>
<span className="op-meta">{obj.url ?? ''}</span>
</div>
@@ -519,7 +519,7 @@ function WebSearchCard({ input, result, runStreaming, runSucceeded }: { input: u
return (
<div className="op-card op-web">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="search" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{t('tool.search')}</span>
<span className="op-meta">{obj.query ?? ''}</span>
</div>
@@ -544,7 +544,7 @@ function GenericCard({
return (
<div className="op-card op-generic">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<ResultBadge icon="blocks" result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{name}</span>
{summary ? <span className="op-meta">{truncate(summary, 200)}</span> : null}
</div>
@@ -552,12 +552,29 @@ function GenericCard({
);
}
function ResultBadge({ result, runStreaming, runSucceeded }: { result?: Props['result']; runStreaming: boolean; runSucceeded: boolean }) {
function ResultBadge({
icon,
result,
runStreaming,
runSucceeded,
}: {
icon: IconName;
result?: Props['result'];
runStreaming: boolean;
runSucceeded: boolean;
}) {
const t = useT();
if (!result && runStreaming) return <span className="op-status op-status-running" title={t('tool.running')}><Icon name="spinner" size={14} /></span>;
if (!result && !runSucceeded) return <span className="op-status op-status-error" title={t('tool.error')}><Icon name="close" size={14} /></span>;
if (!result && !runStreaming && !runSucceeded) return <span className="op-status op-status-error" title={t('tool.error')}><Icon name="close" size={14} /></span>;
if (result?.isError) return <span className="op-status op-status-error" title={result.content || t('tool.error')}><Icon name="close" size={14} /></span>;
return <span className="op-status op-status-ok" title={t('tool.done')}><Icon name="check" size={14} /></span>;
return (
<span
className={`op-status op-status-category${runStreaming && !result ? ' op-status-running' : ''}`}
data-tool-category={icon}
title={runStreaming && !result ? t('tool.running') : t('tool.done')}
>
<Icon name={icon} size={14} />
</span>
);
}
function FileErrorDetail({ result }: { result?: Props['result'] }) {
+39 -10
View File
@@ -1583,19 +1583,48 @@
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.task-activity-count {
flex: 0 0 auto;
min-width: 18px;
padding: 1px 5px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-pill);
color: var(--text-faint);
font-size: 10px;
.task-activity .action-card-toggle {
width: fit-content;
padding: 4px 0;
gap: 5px;
}
.task-activity .action-card-toggle:hover {
background: transparent;
}
.task-activity .action-card-toggle .summary {
flex: 0 1 auto;
font-weight: 400;
}
.task-activity-elapsed {
color: var(--text-muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.task-activity .action-card-body {
padding-top: 4px;
position: relative;
padding: 8px 0;
gap: 6px;
}
.task-activity .action-card-body::before {
content: '';
position: absolute;
z-index: 0;
top: 20px;
bottom: 20px;
left: 15.5px;
width: 1px;
background: var(--border-soft);
}
.task-activity .action-card-body > * {
position: relative;
z-index: 1;
}
.task-activity .action-card-toggle .chev {
transform: rotate(0deg);
transition: transform 200ms var(--ease-out);
}
.task-activity.is-open .action-card-toggle .chev {
transform: rotate(180deg);
}
.action-card-toggle .chev { color: var(--text-faint); font-size: 10px; flex-shrink: 0; transition: transform 120ms var(--ease-out); }
.action-card-body {
+20
View File
@@ -1341,6 +1341,18 @@
padding: 8px 0 8px 32px;
border-top: none;
}
.app .task-activity .action-card-toggle {
width: fit-content;
min-height: 32px;
padding: 4px 0;
gap: 5px;
}
.app .task-activity .action-card-toggle:hover {
background: transparent;
}
.app .task-activity .action-card-body {
padding: 8px 0;
}
.app .op-card {
border: none;
border-radius: 0;
@@ -1381,6 +1393,10 @@
border: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.app .op-status.op-status-category {
border-color: transparent;
background: var(--bg-panel);
}
.app .thinking-block {
background: none;
border: none;
@@ -1404,6 +1420,10 @@
border: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.app .task-activity .thinking-status {
border-color: transparent;
background: var(--bg-panel);
}
.app .thinking-label {
font-size: 13px;
}
+5
View File
@@ -29,6 +29,11 @@
.op-status-running { color: var(--purple); }
.op-status-ok { color: var(--green); }
.op-status-error { color: var(--red); }
.op-status-category {
border-color: transparent;
background: var(--bg-panel);
color: var(--text-muted);
}
.op-toggle {
font-size: 10.5px;
padding: 2px 8px;
@@ -38,8 +38,9 @@ describe('AssistantMessage tool status', () => {
/>,
);
expect(container.querySelector('.action-card-status.op-status-ok')).not.toBeNull();
expect(container.querySelector('.action-card-status.op-status-running')).toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('Done');
expect(container.querySelector('[data-tool-category="terminal"]')).not.toBeNull();
expect(container.querySelector('.op-status-error')).toBeNull();
});
it('keeps legacy completed messages without runStatus as Done', () => {
@@ -63,8 +64,9 @@ describe('AssistantMessage tool status', () => {
/>,
);
expect(container.querySelector('.action-card-status.op-status-ok')).not.toBeNull();
expect(container.querySelector('.action-card-status.op-status-running')).toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('Done');
expect(container.querySelector('[data-tool-category="terminal"]')).not.toBeNull();
expect(container.querySelector('.op-status-error')).toBeNull();
});
it('shows Done in a grouped completed run when tool results are missing', () => {
@@ -92,7 +94,8 @@ describe('AssistantMessage tool status', () => {
);
expect(container.querySelector('.action-card-toggle.running')).toBeNull();
expect(container.querySelector('.op-status-ok, .action-card-status.op-status-ok')).not.toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('Done');
expect(container.querySelectorAll('[data-tool-category="terminal"]')).toHaveLength(2);
});
it('does not group duplicate tool_use records with the same id', () => {
@@ -177,6 +180,9 @@ describe('AssistantMessage tool status', () => {
fireEvent.click(activity);
expect(activity.getAttribute('aria-expanded')).toBe('true');
expect(container.querySelectorAll('.op-card')).toHaveLength(3);
expect(container.querySelector('[data-tool-category="eye"]')).not.toBeNull();
expect(container.querySelector('[data-tool-category="file-code"]')).not.toBeNull();
expect(container.querySelector('[data-tool-category="terminal"]')).not.toBeNull();
});
it('does not show Done when a failed run is missing a tool result', () => {
@@ -200,8 +206,9 @@ describe('AssistantMessage tool status', () => {
/>,
);
expect(container.querySelector('.action-card-status.op-status-error')).not.toBeNull();
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('error');
expect(container.querySelector('.op-status-error')).not.toBeNull();
expect(container.querySelector('.op-status-ok')).toBeNull();
});
it('does not show Done when a canceled run is missing a tool result', () => {
@@ -225,8 +232,9 @@ describe('AssistantMessage tool status', () => {
/>,
);
expect(container.querySelector('.action-card-status.op-status-error')).not.toBeNull();
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('error');
expect(container.querySelector('.op-status-error')).not.toBeNull();
expect(container.querySelector('.op-status-ok')).toBeNull();
});
it('keeps Running for a streaming tool use that has no tool result', () => {
@@ -251,8 +259,37 @@ describe('AssistantMessage tool status', () => {
/>,
);
expect(container.querySelector('.action-card-status.op-status-running')).not.toBeNull();
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
expect(screen.getByTestId('task-activity-toggle').textContent).toContain('Working');
expect(container.querySelector('[data-tool-category="terminal"].op-status-running')).not.toBeNull();
expect(container.querySelector('.op-status-ok')).toBeNull();
});
it('keeps the run state above the answer and groups thinking into the timeline', () => {
const { container } = render(
<AssistantMessage
projectKind="prototype"
conversationId="conv-1"
message={messageWithEvents([
{ kind: 'thinking', text: 'Reviewing the request.' },
{ kind: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/repo/source.ts' } },
{ kind: 'tool_result', toolUseId: 'tool-1', content: 'source', isError: false },
{ kind: 'text', text: 'Here is the finished answer.' },
])}
streaming={false}
projectId="project-1"
/>,
);
const flow = container.querySelector('.assistant-flow');
const activity = screen.getByTestId('task-activity-toggle');
expect(flow?.firstElementChild?.classList.contains('task-activity')).toBe(true);
expect(activity.textContent).toContain('Done');
expect(flow?.textContent).toContain('Here is the finished answer.');
fireEvent.click(activity);
const activityCard = activity.closest('.task-activity');
expect(activityCard?.querySelector('.thinking-block')).not.toBeNull();
expect(activityCard?.querySelector('[data-tool-category="eye"]')).not.toBeNull();
});
it('renders URLs in JSON-like status details without trailing structural characters', () => {