feat(web): stream one chat activity at a time

This commit is contained in:
xiaoche-hub
2026-07-16 19:44:48 +08:00
parent 37960751f0
commit 396787af13
3 changed files with 240 additions and 4 deletions
@@ -596,6 +596,9 @@ function AssistantMessageImpl({
() => splitTaskActivity(blocks),
[blocks],
);
const hasConclusion = contentBlocks.some(
(block) => block.kind === "text" && block.text.trim().length > 0,
);
const fileOps = useMemo(() => deriveFileOps(displayEvents), [displayEvents]);
const produced = message.producedFiles ?? [];
const displayedProduced = useMemo(
@@ -872,6 +875,7 @@ function AssistantMessageImpl({
<TaskActivityCard
entries={taskActivity.entries}
trailingThinking={taskActivity.trailingThinking}
hasConclusion={hasConclusion}
runStreaming={streaming}
runSucceeded={runSucceeded}
runFailed={
@@ -3651,6 +3655,7 @@ function ToolGroupCard({
function TaskActivityCard({
entries,
trailingThinking,
hasConclusion,
runStreaming,
runSucceeded,
runFailed,
@@ -3663,6 +3668,7 @@ function TaskActivityCard({
}: {
entries: TaskActivityEntry[];
trailingThinking: boolean;
hasConclusion: boolean;
runStreaming: boolean;
runSucceeded: boolean;
runFailed: boolean;
@@ -3675,6 +3681,10 @@ function TaskActivityCard({
}) {
const t = useT();
const [open, setOpen] = useState(false);
const shouldCollapse = hasConclusion || !runStreaming;
useEffect(() => {
if (shouldCollapse) setOpen(false);
}, [shouldCollapse]);
const toolItems = entries
.filter((entry): entry is Extract<TaskActivityEntry, { kind: "tool" }> => entry.kind === "tool")
.map((entry) => entry.item);
@@ -3683,6 +3693,8 @@ function TaskActivityCard({
const visibleEntries = entries.filter(
(entry) => entry.kind !== "tool" || visibleTools.has(entry.item),
);
const currentIndex = visibleEntries.length - 1;
const currentEntry = currentIndex >= 0 ? visibleEntries[currentIndex] : undefined;
const running = runStreaming;
const hasError =
!runStreaming &&
@@ -3698,6 +3710,29 @@ function TaskActivityCard({
const runState = running ? "running" : hasError ? "error" : "completed";
const elapsed = useLiveElapsed(runStreaming, startedAt, endedAt, durationMs);
if (running && !hasConclusion && currentEntry) {
return (
<div
className="action-card task-activity task-activity-current"
data-run-state="running"
data-testid="task-activity-current"
aria-live="polite"
aria-atomic="true"
>
<div
key={taskActivityEntryKey(currentEntry, currentIndex)}
className="task-activity-current-row"
>
<CurrentTaskActivityRow
entry={currentEntry}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
</div>
</div>
);
}
return (
<div className={`action-card task-activity${open ? " is-open" : ""}`}>
<button
@@ -3708,6 +3743,11 @@ function TaskActivityCard({
data-run-state={runState}
data-testid="task-activity-toggle"
>
{!running && !hasError ? (
<span className="task-activity-complete-icon" aria-hidden>
<Icon name="check" size={13} />
</span>
) : null}
<span className={`summary${running ? " shimmer-text" : ""}`}>{stateLabel}</span>
{elapsed ? <span className="task-activity-elapsed">{elapsed}</span> : null}
<span className="chev" aria-hidden>
@@ -3743,6 +3783,14 @@ function TaskActivityCard({
/>
);
})}
{!running && !hasError ? (
<div className="task-activity-terminal" data-testid="task-activity-terminal">
<span className="task-activity-terminal-icon" aria-hidden>
<Icon name="check" size={13} />
</span>
<span>{t("tool.done")}</span>
</div>
) : null}
</div>
</div>
</div>
@@ -3750,6 +3798,47 @@ function TaskActivityCard({
);
}
function taskActivityEntryKey(entry: TaskActivityEntry, index: number): string {
if (entry.kind === "thinking") return `thinking-${index}`;
if (entry.kind === "live-tool") return `live-${entry.id}`;
return `tool-${entry.item.use.id || index}`;
}
function CurrentTaskActivityRow({
entry,
projectFileNames,
onRequestOpenFile,
}: {
entry: TaskActivityEntry;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
}) {
const t = useT();
if (entry.kind === "thinking") {
return (
<div className="task-activity-current-thinking">
<span className="op-status op-status-category" aria-hidden>
<Icon name="sparkles" size={14} />
</span>
<span className="op-title shimmer-text">{t("assistant.thinking")}</span>
</div>
);
}
if (entry.kind === "live-tool") {
return <LiveCodeBox name={entry.name} raw={entry.raw} />;
}
return (
<ToolCard
use={entry.item.use}
result={entry.item.result}
runStreaming
runSucceeded={false}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
);
}
function summarizeGroup(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
+79
View File
@@ -1414,6 +1414,85 @@
border-color: transparent;
background: var(--bg-panel);
}
@keyframes task-activity-current-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.app .task-activity-current {
min-height: 36px;
overflow: hidden;
}
.app .task-activity-current-row {
width: fit-content;
max-width: 100%;
animation: task-activity-current-enter 200ms var(--ease-out);
}
.app .task-activity-current-row .op-card,
.app .task-activity-current-row .live-code-box {
width: fit-content;
max-width: 100%;
padding: 0;
border: none;
background: transparent;
pointer-events: none;
}
.app .task-activity-current-row .op-card-head,
.app .task-activity-current-thinking {
display: flex;
align-items: center;
min-height: 36px;
gap: 6px;
padding: 4px 0;
background: transparent;
}
.app .task-activity-current-row .op-card-head:hover {
background: transparent;
}
.app .task-activity-current-row .op-status-running,
.app .task-activity-current-row .op-status-category,
.app .task-activity-current-row .op-title,
.app .task-activity-current-row .op-meta {
color: var(--text-muted);
}
.app .task-activity-current-row .op-expand-chev,
.app .task-activity-current-row .accordion-collapsible,
.app .task-activity-current-row .live-code-pre,
.app .task-activity-current-row .op-output {
display: none;
}
.app .task-activity-complete-icon,
.app .task-activity-terminal-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid currentColor;
border-radius: 50%;
background: var(--bg-panel);
}
.app .task-activity-terminal {
min-height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
color: var(--green);
font-size: 13px;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.app .task-activity-current-row {
animation: none;
}
}
.app .thinking-block {
background: none;
border: none;
@@ -41,7 +41,7 @@ describe('AssistantMessage tool status', () => {
const activity = screen.getByTestId('task-activity-toggle');
expect(activity.textContent).toContain('Done');
expect(activity.getAttribute('data-run-state')).toBe('completed');
expect(activity.querySelector('.task-activity-status')).toBeNull();
expect(activity.querySelector('.task-activity-complete-icon')).not.toBeNull();
expect(container.querySelector('[data-tool-category="terminal"]')).not.toBeNull();
expect(container.querySelector('.op-status-error')).toBeNull();
});
@@ -265,14 +265,81 @@ describe('AssistantMessage tool status', () => {
/>,
);
const activity = screen.getByTestId('task-activity-toggle');
expect(activity.textContent).toContain('Working');
const activity = screen.getByTestId('task-activity-current');
expect(activity.textContent).toContain('Bash');
expect(activity.textContent).toContain('Run guard');
expect(activity.getAttribute('data-run-state')).toBe('running');
expect(activity.querySelector('.task-activity-status')).toBeNull();
expect(screen.queryByTestId('task-activity-toggle')).toBeNull();
expect(activity.querySelector('.task-activity-complete-icon')).toBeNull();
expect(container.querySelector('[data-tool-category="terminal"].op-status-running')).not.toBeNull();
expect(container.querySelector('.op-status-ok')).toBeNull();
});
it('replaces the single live progress row and collapses the history when prose starts', () => {
const renderMessage = (
events: AgentEvent[],
options: { streaming: boolean; runStatus: ChatMessage['runStatus']; endedAt?: number },
) => (
<AssistantMessage
projectKind="prototype"
conversationId="conv-1"
message={{
...messageWithEvents(events),
endedAt: options.endedAt,
runStatus: options.runStatus,
}}
streaming={options.streaming}
projectId="project-1"
/>
);
const thinking = { kind: 'thinking', text: 'Reviewing the request.' } satisfies AgentEvent;
const read = {
kind: 'tool_use',
id: 'tool-1',
name: 'Read',
input: { file_path: '/repo/source.ts' },
} satisfies AgentEvent;
const { rerender } = render(renderMessage(
[thinking],
{ streaming: true, runStatus: 'running' },
));
expect(screen.getByTestId('task-activity-current').textContent).toContain('Thinking');
expect(screen.queryByTestId('task-activity-toggle')).toBeNull();
rerender(renderMessage(
[thinking, read],
{ streaming: true, runStatus: 'running' },
));
const currentRead = screen.getByTestId('task-activity-current');
expect(currentRead.textContent).toContain('Read');
expect(currentRead.textContent).toContain('source.ts');
expect(currentRead.textContent).not.toContain('Thinking');
rerender(renderMessage(
[thinking, read, { kind: 'text', text: 'Here is the conclusion.' }],
{ streaming: true, runStatus: 'running' },
));
expect(screen.queryByTestId('task-activity-current')).toBeNull();
const concludingActivity = screen.getByTestId('task-activity-toggle');
expect(concludingActivity.getAttribute('aria-expanded')).toBe('false');
expect(concludingActivity.textContent).toContain('Working');
rerender(renderMessage(
[
thinking,
read,
{ kind: 'tool_result', toolUseId: 'tool-1', content: 'source', isError: false },
{ kind: 'text', text: 'Here is the conclusion.' },
],
{ streaming: false, runStatus: 'succeeded', endedAt: 3_000 },
));
const completedActivity = screen.getByTestId('task-activity-toggle');
expect(completedActivity.getAttribute('aria-expanded')).toBe('false');
expect(completedActivity.textContent).toContain('Done');
expect(completedActivity.querySelector('.task-activity-complete-icon')).not.toBeNull();
});
it('keeps the run state above the answer and groups thinking into the timeline', () => {
const { container } = render(
<AssistantMessage
@@ -299,6 +366,7 @@ describe('AssistantMessage tool status', () => {
const activityCard = activity.closest('.task-activity');
expect(activityCard?.querySelector('.thinking-block')).not.toBeNull();
expect(activityCard?.querySelector('[data-tool-category="eye"]')).not.toBeNull();
expect(screen.getByTestId('task-activity-terminal').textContent).toContain('done');
});
it('renders URLs in JSON-like status details without trailing structural characters', () => {