mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Simplify chat execution disclosures
This commit is contained in:
@@ -3503,7 +3503,16 @@ function ToolGroupCard({
|
||||
onRequestOpenFile?: (name: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
// While a task is running, expose the current execution detail. Once it
|
||||
// settles, retain just the activity summary and let the user expand it for
|
||||
// command-level evidence. This gives a task one readable progress line
|
||||
// instead of a stack of finished tool cards.
|
||||
const [open, setOpen] = useState(runStreaming);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userToggled) setOpen(runStreaming);
|
||||
}, [runStreaming, userToggled]);
|
||||
|
||||
// Snapshot-style tools (TodoWrite and friends) replace their whole state on
|
||||
// each call, so a turn that wrote the list several times would otherwise
|
||||
@@ -3511,30 +3520,19 @@ function ToolGroupCard({
|
||||
// snapshot; every other tool passes through untouched.
|
||||
items = dedupeSnapshotToolRetries(items);
|
||||
|
||||
// A run of one tool collapses to that tool's card directly so we don't
|
||||
// wrap a single child in a redundant disclosure.
|
||||
if (items.length === 1) {
|
||||
return (
|
||||
<ToolCard
|
||||
use={items[0]!.use}
|
||||
result={items[0]!.result}
|
||||
runStreaming={runStreaming}
|
||||
runSucceeded={runSucceeded}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeGroup(items, t, runStreaming, runSucceeded);
|
||||
const running = runStreaming && items.some((it) => !it.result);
|
||||
const hasError = items.some((it) => it.result?.isError);
|
||||
const hasError =
|
||||
items.some((it) => it.result?.isError) || (!runStreaming && !runSucceeded);
|
||||
return (
|
||||
<div className="action-card">
|
||||
<button
|
||||
type="button"
|
||||
className={`action-card-toggle ${running ? "running" : ""}`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className={`action-card-status ${running ? 'op-status-running' : hasError ? 'op-status-error' : 'op-status-ok'}`} aria-hidden>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* future surfaces (sidebar, log export, etc.) without coupling to
|
||||
* AssistantMessage's render shape.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import {
|
||||
@@ -24,9 +24,8 @@ import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
entries: FileOpEntry[];
|
||||
/** True while the parent run is still streaming. Drives default-open
|
||||
* state (collapsed when active, expanded once done) and the live-pulse
|
||||
* styling. */
|
||||
/** True while the parent run is still streaming. Drives the live-pulse
|
||||
* styling; file-level detail stays behind the disclosure in both states. */
|
||||
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
|
||||
@@ -56,14 +55,11 @@ export function FileOpsSummary({
|
||||
onRequestOpenFile,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
// Collapsed while streaming so the running pill stays compact; once
|
||||
// the run finishes we open it so the user lands on the full file list
|
||||
// without an extra click. Manual toggles win after that.
|
||||
const [open, setOpen] = useState<boolean>(!streaming);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!userToggled && !streaming) setOpen(true);
|
||||
}, [streaming, userToggled]);
|
||||
// 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.
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -83,7 +79,6 @@ export function FileOpsSummary({
|
||||
type="button"
|
||||
className="file-ops-toggle"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -3447,8 +3447,8 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
||||
transition: opacity 150ms var(--ease-out);
|
||||
}
|
||||
.msg.assistant:hover .assistant-footer,
|
||||
.assistant-footer[data-streaming="true"],
|
||||
.assistant-footer[data-last="true"] { opacity: 1; }
|
||||
.msg.assistant:focus-within .assistant-footer,
|
||||
.assistant-footer[data-streaming="true"] { opacity: 1; }
|
||||
.assistant-footer .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
||||
@@ -1278,8 +1278,8 @@
|
||||
transition: opacity 150ms var(--ease-out);
|
||||
}
|
||||
.app .msg.assistant:hover .assistant-footer,
|
||||
.app .assistant-footer[data-streaming="true"],
|
||||
.app .assistant-footer[data-last="true"] { opacity: 1; }
|
||||
.app .msg.assistant:focus-within .assistant-footer,
|
||||
.app .assistant-footer[data-streaming="true"] { opacity: 1; }
|
||||
.app .assistant-footer .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('FileOpsSummary', () => {
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('opens by default once the run is no longer streaming and lists every touched file', () => {
|
||||
it('keeps file-level detail collapsed after completion until the user asks for it', () => {
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
@@ -61,12 +61,17 @@ 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');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
|
||||
expect(screen.getByTestId('file-ops-row-b.ts')).toBeTruthy();
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('reopens once streaming flips to false unless the user collapsed it manually', () => {
|
||||
it('stays collapsed when streaming flips to false', () => {
|
||||
const { rerender } = render(
|
||||
<FileOpsSummary
|
||||
entries={[entry({ path: 'a.ts' })]}
|
||||
@@ -81,7 +86,7 @@ describe('FileOpsSummary', () => {
|
||||
streaming={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('true');
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('shows the open button only for files that are present in the project file set', () => {
|
||||
@@ -98,6 +103,7 @@ describe('FileOpsSummary', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-ops-toggle'));
|
||||
expect(screen.getByTestId('file-ops-row-open-a.ts')).toBeTruthy();
|
||||
expect(screen.queryByTestId('file-ops-row-open-missing.ts')).toBeNull();
|
||||
|
||||
@@ -118,6 +124,7 @@ describe('FileOpsSummary', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-ops-toggle'));
|
||||
expect(screen.getByTestId('file-ops-row-gone.ts')).toBeTruthy();
|
||||
expect(screen.queryByTestId('file-ops-row-open-gone.ts')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
||||
import type { AgentEvent, ChatMessage } from '../../src/types';
|
||||
@@ -38,8 +38,8 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.op-status-ok')).not.toBeNull();
|
||||
expect(container.querySelector('.op-status-running')).toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-ok')).not.toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-running')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps legacy completed messages without runStatus as Done', () => {
|
||||
@@ -63,8 +63,8 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.op-status-ok')).not.toBeNull();
|
||||
expect(container.querySelector('.op-status-running')).toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-ok')).not.toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-running')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Done in a grouped completed run when tool results are missing', () => {
|
||||
@@ -125,8 +125,12 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.action-card-toggle')).toBeNull();
|
||||
expect(container.querySelectorAll('.op-card.op-file')).toHaveLength(1);
|
||||
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');
|
||||
expect(container.querySelector('[data-testid="file-ops-toggle"]')?.textContent).toContain('Write 1');
|
||||
expect(container.textContent).not.toContain('×2');
|
||||
});
|
||||
@@ -152,8 +156,8 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.op-status-error')).not.toBeNull();
|
||||
expect(container.querySelector('.op-status-ok')).toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-error')).not.toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show Done when a canceled run is missing a tool result', () => {
|
||||
@@ -177,8 +181,8 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.op-status-error')).not.toBeNull();
|
||||
expect(container.querySelector('.op-status-ok')).toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-error')).not.toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps Running for a streaming tool use that has no tool result', () => {
|
||||
@@ -203,8 +207,8 @@ describe('AssistantMessage tool status', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.op-status-running')).not.toBeNull();
|
||||
expect(container.querySelector('.op-status-ok')).toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-running')).not.toBeNull();
|
||||
expect(container.querySelector('.action-card-status.op-status-ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders URLs in JSON-like status details without trailing structural characters', () => {
|
||||
|
||||
Reference in New Issue
Block a user