mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
refactor(web): clarify chat activity timeline
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user