mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
refactor(web): consolidate chat task activity
This commit is contained in:
@@ -586,6 +586,16 @@ function AssistantMessageImpl({
|
||||
},
|
||||
);
|
||||
}, [displayEvents, liveCodeBlocks, showConversationTodoCard, conversationTodoInput]);
|
||||
// A task gets one execution-record disclosure. This keeps answer prose
|
||||
// readable when an agent has alternated between many tools, while retaining
|
||||
// every command, file operation, and streaming code preview for review.
|
||||
// TodoWrite is deliberately left in place: ChatPane owns its single
|
||||
// conversation-level progress card, which is a different user-facing
|
||||
// contract from the per-turn execution record.
|
||||
const { contentBlocks, taskActivity } = useMemo(
|
||||
() => splitTaskActivity(blocks),
|
||||
[blocks],
|
||||
);
|
||||
const fileOps = useMemo(() => deriveFileOps(displayEvents), [displayEvents]);
|
||||
const produced = message.producedFiles ?? [];
|
||||
const displayedProduced = useMemo(
|
||||
@@ -821,7 +831,10 @@ function AssistantMessageImpl({
|
||||
// files) the footer shimmers "Preparing…"; the moment content lands it
|
||||
// flips to "Working". The elapsed clock stays anchored to the persisted run
|
||||
// start so switching project tabs or remounting the message cannot restart it.
|
||||
const hasContent = blocks.some((b) => b.kind !== "status") || turnFileOps.length > 0;
|
||||
const hasContent =
|
||||
contentBlocks.some((b) => b.kind !== "status") ||
|
||||
taskActivity !== null ||
|
||||
turnFileOps.length > 0;
|
||||
const preparing = streaming && !hasContent;
|
||||
const preparingStatus = preparing && events.some((e) => e.kind === "status" && e.label === "thinking")
|
||||
? "thinking"
|
||||
@@ -849,7 +862,7 @@ function AssistantMessageImpl({
|
||||
<span className="role-name">{roleName}</span>
|
||||
</div>
|
||||
<div className="assistant-flow">
|
||||
{blocks.map((b, i) => {
|
||||
{contentBlocks.map((b, i) => {
|
||||
if (b.kind === "text")
|
||||
return (
|
||||
<ProseBlock
|
||||
@@ -899,7 +912,10 @@ function AssistantMessageImpl({
|
||||
);
|
||||
}
|
||||
if (b.kind === "live-tool") {
|
||||
return <LiveCodeBox key={b.id} name={b.name} raw={b.raw} />;
|
||||
// splitTaskActivity moves live code into the same execution
|
||||
// disclosure as settled tools. Keep this branch defensive in
|
||||
// case a future block type opts out of that collection.
|
||||
return null;
|
||||
}
|
||||
if (b.kind === "plugin-candidate") {
|
||||
return (
|
||||
@@ -925,6 +941,16 @@ 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}
|
||||
@@ -3571,6 +3597,97 @@ 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.
|
||||
*/
|
||||
function TaskActivityCard({
|
||||
items,
|
||||
liveTools,
|
||||
runStreaming,
|
||||
runSucceeded,
|
||||
projectFileNames,
|
||||
onRequestOpenFile,
|
||||
}: {
|
||||
items: ToolItem[];
|
||||
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
|
||||
runStreaming: boolean;
|
||||
runSucceeded: boolean;
|
||||
projectFileNames?: Set<string>;
|
||||
onRequestOpenFile?: (name: string) => void;
|
||||
}) {
|
||||
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 summary =
|
||||
settledItems.length > 0
|
||||
? summarizeTaskActivity(settledItems, t, running, runSucceeded)
|
||||
: t("assistant.verbRunning");
|
||||
const count = settledItems.length + liveTools.length;
|
||||
|
||||
return (
|
||||
<div className="action-card task-activity">
|
||||
<button
|
||||
type="button"
|
||||
className={`action-card-toggle ${running ? "running" : ""}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
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="chev" aria-hidden>
|
||||
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
|
||||
</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} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeGroup(
|
||||
items: ToolItem[],
|
||||
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
|
||||
@@ -3592,6 +3709,28 @@ 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";
|
||||
@@ -3676,6 +3815,44 @@ type Block =
|
||||
}
|
||||
| { kind: "status"; label: string; detail?: string | undefined };
|
||||
|
||||
type TaskActivity = {
|
||||
items: ToolItem[];
|
||||
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
|
||||
};
|
||||
|
||||
function splitTaskActivity(blocks: Block[]): {
|
||||
contentBlocks: Block[];
|
||||
taskActivity: TaskActivity | null;
|
||||
} {
|
||||
const contentBlocks: Block[] = [];
|
||||
const items: ToolItem[] = [];
|
||||
const liveTools: TaskActivity["liveTools"] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "live-tool") {
|
||||
liveTools.push(block);
|
||||
continue;
|
||||
}
|
||||
if (block.kind === "tool-group") {
|
||||
// The canonical TodoWrite display is kept in its anchored chat row. It
|
||||
// remains the one task-progress surface, rather than becoming another
|
||||
// item inside the execution audit trail.
|
||||
if (block.items.every((item) => isTodoWriteToolName(item.use.name))) {
|
||||
contentBlocks.push(block);
|
||||
} else {
|
||||
items.push(...block.items);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
contentBlocks.push(block);
|
||||
}
|
||||
|
||||
return {
|
||||
contentBlocks,
|
||||
taskActivity: items.length > 0 || liveTools.length > 0 ? { items, liveTools } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the event stream and build the rendering layout list. We additionally
|
||||
* collapse runs of consecutive tool_uses sharing the same tool family into a
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* "Files this turn" disclosure pinned to the top of an assistant message.
|
||||
*
|
||||
* The row appears as a compact summary with operation counters
|
||||
* (Write 1 · Edit 2 · Read 3). File-level detail is available on demand,
|
||||
* with per-file op badges and an "Open" button that lifts the basename up
|
||||
* to ProjectView so FileWorkspace focuses the matching tab.
|
||||
* The row stays compact with live counters (Write 1 · Edit 2 · Read 3).
|
||||
* Opening its disclosure reveals the full file list with per-file op badges;
|
||||
* a changed result file gets one direct "Open" action that lifts the basename
|
||||
* up to ProjectView so FileWorkspace focuses the matching tab.
|
||||
*
|
||||
* The component is read-only over `events` — derivation lives in
|
||||
* `runtime/file-ops.ts` so the same logic is reachable from tests and
|
||||
@@ -23,8 +23,7 @@ import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
entries: FileOpEntry[];
|
||||
/** True while the parent run is still streaming. Drives the live-pulse
|
||||
* styling; file-level detail stays behind the disclosure in both states. */
|
||||
/** True while the parent run is still streaming. Drives live-pulse styling. */
|
||||
streaming: boolean;
|
||||
/** Names that exist in the project folder. When set, the open button
|
||||
* only shows for entries whose basename is in the set. Pass undefined
|
||||
@@ -54,10 +53,9 @@ export function FileOpsSummary({
|
||||
onRequestOpenFile,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
// The header is the durable result summary. Keep the per-file operation
|
||||
// list collapsed by default both while running and after completion, so a
|
||||
// long task does not leave a second, competing result card in the chat.
|
||||
// It remains one click away for review and manual toggles always win.
|
||||
// File details are review material, not the result itself. Keep them
|
||||
// collapsed in every terminal state so a completed task reads as answer →
|
||||
// delivery, while preserving a single disclosure for audit.
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
@@ -69,30 +67,55 @@ export function FileOpsSummary({
|
||||
if (counts.delete > 0) summaryParts.push(`${t('tool.delete')} ${counts.delete}`);
|
||||
if (counts.read > 0) summaryParts.push(`${t('tool.read')} ${counts.read}`);
|
||||
|
||||
// Prefer the latest changed file as the result entry point. Read-only turns
|
||||
// intentionally have no primary action: opening an arbitrary input would
|
||||
// make the execution record look like a delivery card.
|
||||
const primaryEntry = [...entries]
|
||||
.reverse()
|
||||
.find((entry) =>
|
||||
!entry.ops.includes('delete') &&
|
||||
(entry.ops.includes('write') || entry.ops.includes('edit')),
|
||||
);
|
||||
const canOpenPrimary =
|
||||
!!primaryEntry &&
|
||||
!!onRequestOpenFile &&
|
||||
(projectFileNames ? projectFileNames.has(primaryEntry.path) : true);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`file-ops${streaming ? ' is-streaming' : ''}`}
|
||||
data-testid="file-ops-summary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="file-ops-toggle"
|
||||
onClick={() => {
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-expanded={open}
|
||||
data-testid="file-ops-toggle"
|
||||
>
|
||||
<span className="file-ops-icon" aria-hidden>
|
||||
<Icon name="file" size={13} />
|
||||
</span>
|
||||
<span className="file-ops-label">{t('assistant.producedFiles')}</span>
|
||||
<span className="file-ops-summary-line">{summaryParts.join(' · ')}</span>
|
||||
<span className="file-ops-count">{entries.length}</span>
|
||||
<span className="file-ops-chev" aria-hidden>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
|
||||
</span>
|
||||
</button>
|
||||
<div className="file-ops-head">
|
||||
<button
|
||||
type="button"
|
||||
className="file-ops-toggle"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
data-testid="file-ops-toggle"
|
||||
>
|
||||
<span className="file-ops-icon" aria-hidden>
|
||||
<Icon name="file" size={13} />
|
||||
</span>
|
||||
<span className="file-ops-label">{t('assistant.producedFiles')}</span>
|
||||
<span className="file-ops-summary-line">{summaryParts.join(' · ')}</span>
|
||||
<span className="file-ops-count">{entries.length}</span>
|
||||
<span className="file-ops-chev" aria-hidden>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
|
||||
</span>
|
||||
</button>
|
||||
{canOpenPrimary && primaryEntry ? (
|
||||
<button
|
||||
type="button"
|
||||
className="file-ops-primary-action"
|
||||
onClick={() => onRequestOpenFile?.(primaryEntry.path)}
|
||||
title={t('tool.openInTab', { name: primaryEntry.path })}
|
||||
data-testid={`file-ops-primary-open-${primaryEntry.path}`}
|
||||
>
|
||||
{t('assistant.openFile')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<ul className="file-ops-list" role="list">
|
||||
{entries.map((entry) => (
|
||||
|
||||
@@ -1583,6 +1583,20 @@
|
||||
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;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
.task-activity .action-card-body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.action-card-toggle .chev { color: var(--text-faint); font-size: 10px; flex-shrink: 0; transition: transform 120ms var(--ease-out); }
|
||||
.action-card-body {
|
||||
padding: 8px 0 8px 32px;
|
||||
|
||||
@@ -284,11 +284,18 @@
|
||||
border-style: dashed;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.file-ops-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-ops-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -299,6 +306,23 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.file-ops-toggle:hover { background: var(--bg-subtle); }
|
||||
.file-ops-primary-action {
|
||||
flex: 0 0 auto;
|
||||
margin: 4px 6px 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 120ms var(--ease-out), border-color 120ms var(--ease-out), background 120ms var(--ease-out);
|
||||
}
|
||||
.file-ops-primary-action:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent);
|
||||
}
|
||||
.file-ops-icon { display: inline-flex; color: var(--text-muted); }
|
||||
.file-ops-label {
|
||||
font-size: 10.5px;
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('FileOpsSummary', () => {
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('keeps file-level detail collapsed after completion until the user asks for it', () => {
|
||||
it('stays collapsed once the run is complete until the user asks for file details', () => {
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
@@ -63,15 +63,14 @@ describe('FileOpsSummary', () => {
|
||||
|
||||
expect(screen.queryByTestId('file-ops-row-a.ts')).toBeNull();
|
||||
expect(screen.queryByTestId('file-ops-row-b.ts')).toBeNull();
|
||||
const toggle = screen.getByTestId('file-ops-toggle');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(screen.getByTestId('file-ops-toggle'));
|
||||
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
|
||||
expect(screen.getByTestId('file-ops-row-b.ts')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stays collapsed when streaming flips to false', () => {
|
||||
it('remains collapsed when a streaming turn finishes', () => {
|
||||
const { rerender } = render(
|
||||
<FileOpsSummary
|
||||
entries={[entry({ path: 'a.ts' })]}
|
||||
@@ -111,6 +110,25 @@ describe('FileOpsSummary', () => {
|
||||
expect(onRequestOpenFile).toHaveBeenCalledWith('a.ts');
|
||||
});
|
||||
|
||||
it('offers one direct result entry point for the latest changed file', () => {
|
||||
const onRequestOpenFile = vi.fn();
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
entry({ path: 'input.ts' }),
|
||||
entry({ path: 'result.ts', ops: ['write'], opCounts: { read: 0, write: 1, edit: 0, delete: 0 } }),
|
||||
]}
|
||||
streaming={false}
|
||||
projectFileNames={new Set(['input.ts', 'result.ts'])}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-ops-primary-open-result.ts'));
|
||||
expect(onRequestOpenFile).toHaveBeenCalledWith('result.ts');
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('does not show the open button for deleted files', () => {
|
||||
const onRequestOpenFile = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
||||
import type { AgentEvent, ChatMessage } from '../../src/types';
|
||||
@@ -125,16 +125,42 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.action-card-toggle');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(container.querySelector('.action-card .accordion-collapsible')?.className).not.toContain('open');
|
||||
if (toggle) fireEvent.click(toggle);
|
||||
expect(container.querySelector('.action-card .accordion-collapsible')?.className).toContain('open');
|
||||
const activity = screen.getByTestId('task-activity-toggle');
|
||||
expect(activity).toBeTruthy();
|
||||
expect(activity.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(activity);
|
||||
expect(activity.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(container.querySelectorAll('.op-card.op-file')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-testid="file-ops-toggle"]')?.textContent).toContain('Write 1');
|
||||
expect(container.textContent).not.toContain('×2');
|
||||
});
|
||||
|
||||
it('collapses mixed tool families into one execution record', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
projectKind="prototype"
|
||||
conversationId="conv-1"
|
||||
message={messageWithEvents([
|
||||
{ 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: 'tool_use', id: 'tool-2', name: 'Write', input: { file_path: '/repo/result.ts', content: 'export {}' } },
|
||||
{ kind: 'tool_result', toolUseId: 'tool-2', content: 'ok', isError: false },
|
||||
{ kind: 'tool_use', id: 'tool-3', name: 'Bash', input: { command: 'pnpm typecheck' } },
|
||||
{ kind: 'tool_result', toolUseId: 'tool-3', content: 'ok', isError: false },
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
const activity = screen.getByTestId('task-activity-toggle');
|
||||
expect(activity).toBeTruthy();
|
||||
expect(activity.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(activity);
|
||||
expect(activity.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(container.querySelectorAll('.op-card')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does not show Done when a failed run is missing a tool result', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage
|
||||
|
||||
Reference in New Issue
Block a user