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