-
{
- if (e.key === 'Enter' || e.key === ' ') {
- onClick();
- }
+ const banner = (
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ onClick();
}
- : undefined
- }
- >
-
- {draftPrompt}
-
- {interactive && (
-
- )}
-
+ }
+ : undefined
+ }
+ >
+
+ {draftPrompt}
+ {interactive && (
+
+ )}
+
);
+
+ return embedded ? banner :
{banner}
;
}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx
index 9726cfd6..073c1fcd 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.tsx
@@ -79,7 +79,7 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) {
-
+
{shouldShowWakeTaskInput && taskRun ? (
-
- ) : (
- isResuming &&
- draftPrompt && (
-
- )
- )}
+
+
+
+ ) : isResuming && draftPrompt ? (
+
+
+
+ ) : null}
@@ -115,6 +117,14 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) {
);
}
+function HistoricalInputTray({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
function ClosePreviewOnSleepEffect({ asleep }: { asleep: boolean }) {
useClosePreviewOnSleep(asleep);
return null;
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
index 6c3b85e9..b77e6faa 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx
@@ -259,20 +259,16 @@ function LiveContentInner({
}
/>
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx
index 33a66b54..7e49c9d8 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx
@@ -88,6 +88,20 @@ vi.mock('./messages/index', () => ({
vi.mock('./messages/acp', () => ({
AcpMessageItem: ({ msg }: { msg: { id: string } }) =>
{msg.id}
,
AcpGroupedToolMessage: () => null,
+ AcpActivityGroupMessage: ({
+ group,
+ children,
+ }: {
+ group: { ts: number; endTs: number };
+ children: ReactNode;
+ }) => (
+
+
+
{children}
+
+ ),
AcpTextMessage: ({ msg }: { msg: { text?: string } }) => (
{msg.text}
),
@@ -482,4 +496,208 @@ describe('Messages', () => {
}),
);
});
+
+ it('collapses eligible background activity between text messages', () => {
+ mockBuildAcpRenderBlocks.mockReturnValue([
+ {
+ kind: 'message',
+ msg: {
+ id: 'assistant-text-1',
+ ts: 1_000,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-1',
+ ts: 2_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'assistant-text-2',
+ ts: 19_000,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ },
+ },
+ ] as never);
+
+ render(
+
,
+ );
+
+ expect(screen.getByText('Worked for 17s')).toBeInTheDocument();
+ expect(screen.getByText('reasoning-1')).toBeInTheDocument();
+ });
+
+ it('uses the rendered session prompt as the left boundary for initial activity', () => {
+ mockBuildAcpRenderBlocks.mockReturnValue([
+ {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-1',
+ ts: 2_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'assistant-text-1',
+ ts: 9_000,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ },
+ },
+ ] as never);
+
+ render(
+
,
+ );
+
+ expect(screen.getByText('Initial prompt')).toBeInTheDocument();
+ expect(screen.getByText('Worked for 7s')).toBeInTheDocument();
+ expect(screen.getByText('reasoning-1')).toBeInTheDocument();
+ });
+
+ it('collapses initial eligible activity even when there is no visible starting text message', () => {
+ mockBuildAcpRenderBlocks.mockReturnValue([
+ {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-1',
+ ts: 2_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'assistant-text-1',
+ ts: 12_000,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ },
+ },
+ ] as never);
+
+ render(
+
,
+ );
+
+ expect(screen.getByText('Worked for 10s')).toBeInTheDocument();
+ expect(screen.getByText('reasoning-1')).toBeInTheDocument();
+ });
+
+ it('keeps todo section markers visible while collapsing following activity', () => {
+ mockBuildAcpRenderBlocks.mockReturnValue([
+ {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-1',
+ ts: 2_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'todo-1',
+ ts: 3_000,
+ role: 'assistant',
+ kind: 'todo_section',
+ partial: false,
+ data: {
+ todoId: 'todo-1',
+ content: 'Inspect repository guidance',
+ },
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-2',
+ ts: 4_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: false,
+ },
+ },
+ {
+ kind: 'message',
+ msg: {
+ id: 'assistant-text-1',
+ ts: 12_000,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ },
+ },
+ ] as never);
+
+ render(
+
,
+ );
+
+ expect(screen.getByText('Worked for 8s')).toBeInTheDocument();
+ expect(screen.getByText('reasoning-1')).toBeInTheDocument();
+ expect(screen.getByText('todo-1')).toBeInTheDocument();
+ expect(screen.getByText('reasoning-2')).toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx
index 70ae33ec..2c6e81c8 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx
@@ -40,8 +40,13 @@ import { SleepWakeMessages } from './messages/index';
import {
AcpMessageItem,
AcpGroupedToolMessage,
+ AcpActivityGroupMessage,
AcpTextMessage,
} from './messages/acp';
+import {
+ buildAcpActivityRenderBlocks,
+ type AcpConversationRenderBlock,
+} from './messages/acp/activity-groups';
import {
buildAcpRenderBlocks,
type AcpRenderBlock,
@@ -120,7 +125,11 @@ function DebugTimestamp({
);
}
-function blockUsesOwnTimestamp(block: AcpRenderBlock): boolean {
+function blockUsesOwnTimestamp(block: AcpConversationRenderBlock): boolean {
+ if (block.kind === 'activity_group') {
+ return false;
+ }
+
if (block.kind === 'tool_group') {
return false;
}
@@ -139,8 +148,14 @@ function blockUsesOwnTimestamp(block: AcpRenderBlock): boolean {
}
}
-function hasVisibleAssistantOutput(blocks: AcpRenderBlock[]): boolean {
+function hasVisibleAssistantOutput(
+ blocks: AcpConversationRenderBlock[],
+): boolean {
return blocks.some((block) => {
+ if (block.kind === 'activity_group') {
+ return hasVisibleAssistantOutput(block.blocks);
+ }
+
if (block.kind === 'tool_group') {
return false;
}
@@ -211,24 +226,30 @@ const MessagesBase = ({
const resolvedHideFirstAcpUserPrompt =
hideFirstAcpUserPrompt ?? shouldRenderSessionPrompt;
- const renderBlocks = useMemo(
- () =>
- buildAcpRenderBlocks(messages, {
- displayMode: resolvedMessageUiOptions.displayMode,
- initialPrompt: resolvedHideFirstAcpUserPrompt ? sessionPrompt : null,
- shouldHideFirstMessage: resolvedHideFirstAcpUserPrompt,
- showInternalMessages,
- suppressedMessageIds,
- }),
- [
- messages,
- resolvedHideFirstAcpUserPrompt,
- resolvedMessageUiOptions.displayMode,
- sessionPrompt,
+ const renderBlocks = useMemo(() => {
+ const acpBlocks = buildAcpRenderBlocks(messages, {
+ displayMode: resolvedMessageUiOptions.displayMode,
+ initialPrompt: resolvedHideFirstAcpUserPrompt ? sessionPrompt : null,
+ shouldHideFirstMessage: resolvedHideFirstAcpUserPrompt,
showInternalMessages,
suppressedMessageIds,
- ],
- );
+ });
+
+ return buildAcpActivityRenderBlocks(acpBlocks, {
+ artifacts: session.artifacts,
+ displayMode: resolvedMessageUiOptions.displayMode,
+ hasLeadingTextBoundary: shouldRenderSessionPrompt,
+ });
+ }, [
+ messages,
+ resolvedHideFirstAcpUserPrompt,
+ resolvedMessageUiOptions.displayMode,
+ session.artifacts,
+ sessionPrompt,
+ shouldRenderSessionPrompt,
+ showInternalMessages,
+ suppressedMessageIds,
+ ]);
const hasVisibleAssistantOutputInTranscript =
hasVisibleAssistantOutput(renderBlocks);
const shouldShowNarrationWorkingReasoning =
@@ -244,25 +265,72 @@ const MessagesBase = ({
));
}
- function renderRenderBlock(block: AcpRenderBlock, nested: boolean) {
+ function renderRenderBlock(
+ block: AcpConversationRenderBlock,
+ nested: boolean,
+ ) {
const timestamp =
showInternalMessages && !blockUsesOwnTimestamp(block) ? (
) : null;
+ if (block.kind === 'activity_group') {
+ const content = (
+
+ {renderNestedBlocks(block.blocks)}
+
+ );
+
+ const blockWithTimestamp = (
+ <>
+ {content}
+ {timestamp}
+ >
+ );
+
+ return nested ? (
+ blockWithTimestamp
+ ) : (
+
+ {blockWithTimestamp}
+
+ );
+ }
+
if (block.kind === 'tool_group') {
const content = (
{
+ if (excluded.has(anchorId)) {
+ return;
+ }
+
+ excluded.add(anchorId);
+ anchorIds.push(anchorId);
+ };
+
+ collectBlockAnchorIdsInto(blocks, pushAnchor);
+
+ return anchorIds;
+}
+
+function collectBlockAnchorIdsInto(
+ blocks: AcpRenderBlock[],
+ pushAnchor: (anchorId: string) => void,
+): void {
+ for (const block of blocks) {
+ if (block.kind === 'tool_group') {
+ pushAnchor(messageAnchorId(block.ts));
+ for (const item of block.items) {
+ pushAnchor(messageAnchorId(item.msg.ts));
+ }
+ continue;
+ }
+
+ pushAnchor(messageAnchorId(block.msg.ts));
+
+ if (block.childBlocks) {
+ collectBlockAnchorIdsInto(block.childBlocks, pushAnchor);
+ }
+ }
+}
+
+function blockHasPartialMessage(block: AcpRenderBlock): boolean {
+ if (block.kind === 'tool_group') {
+ return block.items.some((item) => item.msg.partial);
+ }
+
+ return (
+ block.msg.partial ||
+ Boolean(block.childBlocks?.some(blockHasPartialMessage))
+ );
+}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx
index 7c292702..9b2586f3 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingEnvVarRequestPanel.tsx
@@ -271,7 +271,7 @@ export function PendingEnvVarRequestPanel({
return (
-
+
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestCard.tsx
index c4fa7fad..21be3c25 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestCard.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestCard.tsx
@@ -241,7 +241,7 @@ export function PendingUserInputRequestCard({
}
return (
-
+
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/QueuedMessages.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/QueuedMessages.tsx
index a56e877d..9ca67aa1 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/QueuedMessages.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/QueuedMessages.tsx
@@ -209,7 +209,7 @@ export const QueuedMessagesContent = ({
}
return (
-
+
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx
index f9275bfe..345d3389 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx
@@ -54,7 +54,7 @@ export function TaskInputStack({
/>
{bootingTaskRun ? (
-
+
{
useSandboxTaskPhaseMock.mockReturnValue('running');
});
- it('opens populated todos by default', () => {
+ it('starts collapsed by default', () => {
useSandboxTodosMock.mockReturnValue([
{ id: '1', content: 'Inspect layout', status: 'in_progress' },
{ id: '2', content: 'Move the todo block', status: 'pending' },
@@ -55,9 +55,17 @@ describe('TodoList', () => {
expect(
screen.getByRole('button', { name: /1 of 3 to-dos done/i }),
).toBeVisible();
+ expect(screen.queryByText('Inspect layout')).not.toBeInTheDocument();
+ expect(screen.queryByText('Move the todo block')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Verify prompt alignment'),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /1 of 3 to-dos done/i }),
+ );
+
expect(screen.getByText('Inspect layout')).toBeVisible();
- expect(screen.getByText('Move the todo block')).toBeVisible();
- expect(screen.getByText('Verify prompt alignment')).toBeVisible();
});
it('starts collapsed on mobile task entry', () => {
@@ -93,7 +101,7 @@ describe('TodoList', () => {
const { rerender } = render();
expect(
- screen.getByRole('button', { name: /2 to-dos done/i }),
+ screen.getByRole('button', { name: /All 2 to-dos done/i }),
).toBeVisible();
expect(screen.queryByText('Inspect layout')).not.toBeInTheDocument();
@@ -127,52 +135,29 @@ describe('TodoList', () => {
expect(container).toBeEmptyDOMElement();
});
- it('collapses when the last todo completes, then hides it after 10 seconds', () => {
- vi.useFakeTimers();
-
- try {
- useSandboxTodosMock.mockReturnValue([
- { id: '1', content: 'Inspect layout', status: 'in_progress' },
- { id: '2', content: 'Move the todo block', status: 'completed' },
- ]);
-
- const { rerender } = render();
-
- expect(
- screen.getByRole('button', { name: /1 of 2 to-dos done/i }),
- ).toBeVisible();
- expect(screen.getByText('Inspect layout')).toBeVisible();
-
- useSandboxTodosMock.mockReturnValue([
- { id: '1', content: 'Inspect layout', status: 'completed' },
- { id: '2', content: 'Move the todo block', status: 'completed' },
- ]);
-
- rerender();
+ it('collapses when the last todo completes and stays visible', () => {
+ useSandboxTodosMock.mockReturnValue([
+ { id: '1', content: 'Inspect layout', status: 'in_progress' },
+ { id: '2', content: 'Move the todo block', status: 'completed' },
+ ]);
- expect(
- screen.getByRole('button', { name: /2 to-dos done/i }),
- ).toBeVisible();
- expect(screen.queryByText('Inspect layout')).not.toBeInTheDocument();
+ const { rerender } = render();
- act(() => {
- vi.advanceTimersByTime(9_000);
- });
+ expect(
+ screen.getByRole('button', { name: /1 of 2 to-dos done/i }),
+ ).toBeVisible();
- expect(
- screen.getByRole('button', { name: /2 to-dos done/i }),
- ).toBeVisible();
+ useSandboxTodosMock.mockReturnValue([
+ { id: '1', content: 'Inspect layout', status: 'completed' },
+ { id: '2', content: 'Move the todo block', status: 'completed' },
+ ]);
- act(() => {
- vi.advanceTimersByTime(1_000);
- });
+ rerender();
- expect(
- screen.queryByRole('button', { name: /2 to-dos done/i }),
- ).not.toBeInTheDocument();
- } finally {
- vi.useRealTimers();
- }
+ expect(
+ screen.getByRole('button', { name: /All 2 to-dos done/i }),
+ ).toBeVisible();
+ expect(screen.queryByText('Inspect layout')).not.toBeInTheDocument();
});
it('does not present stale unfinished todos as active when the task is idle', () => {
@@ -184,6 +169,10 @@ describe('TodoList', () => {
const { container } = render();
+ fireEvent.click(
+ screen.getByRole('button', { name: /0 of 2 to-dos done/i }),
+ );
+
const items = Array.from(container.querySelectorAll('li'));
expect(items).toHaveLength(2);
@@ -201,6 +190,10 @@ describe('TodoList', () => {
const { container } = render();
+ fireEvent.click(
+ screen.getByRole('button', { name: /1 of 3 to-dos done/i }),
+ );
+
const items = Array.from(container.querySelectorAll('li'));
expect(items).toHaveLength(3);
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx
index 5d0ab3ea..acf1a6b8 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/TodoList.tsx
@@ -21,7 +21,6 @@ import {
} from './hooks/SandboxProvider';
import { getDisplayedTodoStatus } from './todo-status';
-const HIDE_COMPLETED_TODOS_DELAY_MS = 10_000;
const TODO_INACTIVE_TASK_PHASES: ReadonlySet = new Set([
'idle',
'waiting_for_prompt',
@@ -67,8 +66,7 @@ const TodoListContent = ({ autoCollapseKey, taskEntryKey }: TodoListProps) => {
const completedCount = todos.filter((t) => t.status === 'completed').length;
const allDone = todos.length > 0 && completedCount === todos.length;
- const [isHiddenAfterCompletion, setIsHiddenAfterCompletion] = useState(false);
- const [isOpen, setIsOpen] = useState(() => !allDone);
+ const [isOpen, setIsOpen] = useState(false);
const autoCollapsedKeysRef = useRef(new Set());
const wasAllDoneRef = useRef(allDone);
@@ -104,7 +102,6 @@ const TodoListContent = ({ autoCollapseKey, taskEntryKey }: TodoListProps) => {
useEffect(() => {
if (!allDone) {
- setIsHiddenAfterCompletion(false);
if (wasAllDoneRef.current) {
setIsOpen(true);
}
@@ -114,22 +111,15 @@ const TodoListContent = ({ autoCollapseKey, taskEntryKey }: TodoListProps) => {
wasAllDoneRef.current = true;
setIsOpen(false);
- setIsHiddenAfterCompletion(false);
-
- const hideTimer = window.setTimeout(() => {
- setIsHiddenAfterCompletion(true);
- }, HIDE_COMPLETED_TODOS_DELAY_MS);
-
- return () => window.clearTimeout(hideTimer);
}, [allDone, todoStatusSignature]);
- if (todos.length === 0 || isHiddenAfterCompletion) {
+ if (todos.length === 0) {
return null;
}
return (
-
+
{
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
index 3a6abe00..0be3d59d 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
@@ -24,6 +24,8 @@ const {
let capturedPlaceholder: string | undefined;
let capturedSuggestion: unknown;
let capturedSubmitWithMetaKey: boolean | undefined;
+let capturedSubmitIcon: unknown;
+let capturedSurface: string | undefined;
const submittedFilesRef: {
current: Array<{
url?: string;
@@ -83,6 +85,8 @@ vi.mock('@/components/tasks', () => ({
placeholder,
suggestion,
submitWithMetaKey,
+ submitIcon,
+ surface,
}: {
isBusy: boolean;
promptText: string;
@@ -98,10 +102,14 @@ vi.mock('@/components/tasks', () => ({
placeholder?: string;
suggestion?: unknown;
submitWithMetaKey?: boolean;
+ submitIcon?: unknown;
+ surface?: string;
}) => {
capturedPlaceholder = placeholder;
capturedSuggestion = suggestion;
capturedSubmitWithMetaKey = submitWithMetaKey;
+ capturedSubmitIcon = submitIcon;
+ capturedSurface = surface;
return (
@@ -140,6 +148,8 @@ describe('WakeTaskInput', () => {
capturedPlaceholder = undefined;
capturedSuggestion = undefined;
capturedSubmitWithMetaKey = undefined;
+ capturedSubmitIcon = undefined;
+ capturedSurface = undefined;
submittedFilesRef.current = [];
preparePromptAttachmentsMock.mockResolvedValue({
text: 'Wake up and keep going',
@@ -152,6 +162,47 @@ describe('WakeTaskInput', () => {
useSandboxCurrentUserInfoMock.mockReturnValue(null);
});
+ it('shows a wake icon until the user types a message', () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ renderWithQueryClient(
+
,
+ queryClient,
+ );
+
+ expect(capturedSubmitIcon).toBeTruthy();
+
+ fireEvent.change(screen.getByLabelText('Wake prompt'), {
+ target: { value: 'keep going' },
+ });
+
+ expect(capturedSubmitIcon).toBeUndefined();
+ });
+
+ it('uses an embedded prompt surface when rendered inside a tray', () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ renderWithQueryClient(
+
,
+ queryClient,
+ );
+
+ expect(capturedSurface).toBe('embedded');
+ });
+
it('prefills the sleeping draft, appends an optimistic transcript row, and resumes the task with a deferred prompt', async () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -209,7 +260,7 @@ describe('WakeTaskInput', () => {
}),
]);
expect(toastErrorMock).not.toHaveBeenCalled();
- expect(capturedPlaceholder).toBe('Wake up Roomote with this message');
+ expect(capturedPlaceholder).toBe('Wake up Roomote with a message...');
expect(capturedSuggestion).toBeUndefined();
expect(capturedSubmitWithMetaKey).toBe(false);
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
index fff0f555..36b1d3af 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
@@ -5,20 +5,24 @@ import { toast } from 'sonner';
import type { PromptInputMessage } from '@/components/ai-elements';
import { TaskPromptInput } from '@/components/tasks';
+import { Sun } from '@/components/system';
import { useRestoreTaskRunSnapshot } from '@/hooks/snapshots';
import { preparePromptAttachments } from '@/lib/prompt-attachments';
import type { TaskRunDetail } from '@/lib/server';
+import { cn } from '@/lib/utils';
import { useOptimisticPromptSubmission } from './prompt-input/useOptimisticPromptSubmission';
interface WakeTaskInputProps {
taskRun: Pick
;
initialPrompt?: string;
+ embedded?: boolean;
}
export function WakeTaskInput({
taskRun,
initialPrompt = '',
+ embedded = false,
}: WakeTaskInputProps) {
const {
rollbackOptimisticPromptSubmission,
@@ -115,19 +119,26 @@ export function WakeTaskInput({
}
};
- return (
-
-
-
-
+ const input = (
+
+ : undefined}
+ surface={embedded ? 'embedded' : 'default'}
+ />
);
+
+ return embedded ? input :
{input}
;
}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpActivityGroupMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpActivityGroupMessage.tsx
new file mode 100644
index 00000000..16062174
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpActivityGroupMessage.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import {
+ ChevronRight,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/system';
+import { cn } from '@/lib/utils';
+
+import type { AcpActivityGroupRenderBlock } from './activity-groups';
+
+interface AcpActivityGroupMessageProps {
+ group: AcpActivityGroupRenderBlock;
+ anchorIds?: string[];
+ children: ReactNode;
+}
+
+export function AcpActivityGroupMessage({
+ group,
+ anchorIds = [],
+ children,
+}: AcpActivityGroupMessageProps) {
+ return (
+
+ {anchorIds.map((anchorId) => (
+
+ ))}
+
+
+
+ Worked for {formatWorkedDuration(group.endTs - group.ts)}
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function formatWorkedDuration(durationMs: number): string {
+ const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ if (totalSeconds < 13) {
+ return 'a bit';
+ }
+
+ if (hours > 0) {
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+ }
+
+ if (minutes > 0) {
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
+ }
+
+ return `${seconds}s`;
+}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
index b570c3bf..adeb906c 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
@@ -60,9 +60,7 @@ export const AcpCommandOutputMessage = ({
: `exit ${cmd.exitCode}`
: status === 'failed'
? 'failed'
- : status === 'completed'
- ? 'completed'
- : null;
+ : null;
return (
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
index 71092882..607db3db 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
@@ -7,6 +7,9 @@ import {
FolderIcon,
Loader2,
Search,
+ SquarePen,
+ Terminal,
+ Wrench,
} from '@/components/system';
import {
Message,
@@ -21,8 +24,8 @@ import { messageAnchorId } from '../message-anchor';
import { AcpToolDetails } from './AcpToolDetails';
import { hidesExpandedToolResult } from './tool-detail-visibility';
import type {
- ExplorationStepKind,
GroupedToolCallRenderBlock,
+ GroupedToolDisplayKind,
} from './render-blocks';
interface AcpGroupedToolMessageProps {
@@ -58,7 +61,11 @@ export function AcpGroupedToolMessage({
? 'input-available'
: 'output-available';
- const ToolIcon = groupedToolIcon({ hasFailed, hasRunning });
+ const ToolIcon = groupedToolIcon({
+ displayKind: group.displayKind,
+ hasFailed,
+ hasRunning,
+ });
return (
@@ -80,7 +87,7 @@ export function AcpGroupedToolMessage({
collapsible={showExpandedDetails}
/>
{showExpandedDetails ? (
-
+
{group.items.map((item) => {
const sectionTitle = sanitizeSandboxPathString(
item.objectLabel,
@@ -93,7 +100,7 @@ export function AcpGroupedToolMessage({
{sectionTitle}
@@ -117,28 +124,48 @@ export function AcpGroupedToolMessage({
}
function groupedToolIcon(params: {
+ displayKind: GroupedToolDisplayKind;
hasRunning: boolean;
hasFailed: boolean;
}): LucideIcon {
if (params.hasRunning) return Loader2;
if (params.hasFailed) return AlertCircle;
- return Search;
+ return groupedDisplayKindIcon(params.displayKind);
}
function GroupedToolItemIcon({
- stepKind,
+ displayKind,
className,
}: {
- stepKind: ExplorationStepKind;
+ displayKind: GroupedToolDisplayKind;
className?: string;
}) {
- if (stepKind === 'search') {
- return
;
+ const Icon = groupedDisplayKindIcon(displayKind);
+ return
;
+}
+
+function groupedDisplayKindIcon(
+ displayKind: GroupedToolDisplayKind,
+): LucideIcon {
+ if (displayKind === 'search') {
+ return Search;
+ }
+
+ if (displayKind === 'list') {
+ return FolderIcon;
+ }
+
+ if (displayKind === 'read') {
+ return FileIcon;
+ }
+
+ if (displayKind === 'execute') {
+ return Terminal;
}
- if (stepKind === 'list') {
- return
;
+ if (displayKind === 'edit') {
+ return SquarePen;
}
- return
;
+ return Wrench;
}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTodoSectionMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTodoSectionMessage.tsx
index 117e4bd9..3d0a9780 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTodoSectionMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTodoSectionMessage.tsx
@@ -2,7 +2,7 @@ import { Message, MessageContent } from '@/components/ai-elements';
import { messageAnchorId } from '../message-anchor';
import type { AcpTodoSectionUiMessage } from './types';
-import { ArrowRight } from 'lucide-react';
+import { SquareDashed } from 'lucide-react';
interface AcpTodoSectionMessageProps {
msg: AcpTodoSectionUiMessage;
@@ -12,16 +12,16 @@ export function AcpTodoSectionMessage({ msg }: AcpTodoSectionMessageProps) {
const anchorId = messageAnchorId(msg.ts);
return (
-
+
-
+
Starting on
{msg.data.content}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
index 0b30629d..3f4a21ba 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
@@ -49,7 +49,7 @@ export function AcpToolDetails({
if (isSubagent && subagentPrompt) {
return (
{sanitizeSandboxPathString(subagentPrompt)}
@@ -58,7 +58,14 @@ export function AcpToolDetails({
}
return sanitizedText ? (
-
+
) : (
) : null}
{showCollapsibleContent ? (
-
+
{showExpandedDetails ? (
{
+ it('starts collapsed and expands to reveal activity details', () => {
+ render(
+
+ Hidden activity
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: /Worked for 17s/ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Hidden activity')).not.toBeInTheDocument();
+ expect(document.getElementById('activity-anchor')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /Worked for 17s/ }));
+
+ expect(screen.getByText('Hidden activity')).toBeVisible();
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx
index 18bde42e..2edeaacf 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx
@@ -40,9 +40,13 @@ function buildGroup(): GroupedToolCallRenderBlock {
ts: 100,
action: 'Exploring',
objectSummary: '3 files',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
items: [
{
objectLabel: 'file_a.txt',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
stepKind: 'read',
msg: {
id: 'tool-1',
@@ -72,6 +76,8 @@ function buildGroup(): GroupedToolCallRenderBlock {
},
{
objectLabel: 'file_b.txt',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
stepKind: 'read',
msg: {
id: 'tool-2',
@@ -101,6 +107,8 @@ function buildGroup(): GroupedToolCallRenderBlock {
},
{
objectLabel: 'file_c.txt',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
stepKind: 'read',
msg: {
id: 'tool-3',
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
index e38ff68b..6985d216 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
@@ -46,9 +46,13 @@ function buildGroup(): GroupedToolCallRenderBlock {
ts: 100,
action: 'Exploring',
objectSummary: '2 files',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
items: [
{
objectLabel: 'file_a.txt',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
stepKind: 'read',
msg: {
id: 'tool-1',
@@ -78,6 +82,8 @@ function buildGroup(): GroupedToolCallRenderBlock {
},
{
objectLabel: 'file_b.txt',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
stepKind: 'read',
msg: {
id: 'tool-2',
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
index 6d1be36e..85dad6ae 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
@@ -8,7 +8,12 @@ const codeBlockSpy = vi.fn();
const toolInputSpy = vi.fn();
vi.mock('@/components/ai-elements', () => ({
- CodeBlock: (props: { code: string }) => {
+ CodeBlock: (props: {
+ code: string;
+ variant?: string;
+ highlight?: boolean;
+ className?: string;
+ }) => {
codeBlockSpy(props);
return {props.code}
;
},
@@ -136,7 +141,7 @@ describe('AcpToolDetails', () => {
expect(codeBlockSpy).not.toHaveBeenCalled();
});
- it('keeps using a code block for non-subagent tool output text', () => {
+ it('uses a light compact code block for non-subagent tool output text', () => {
render(
{
expect(screen.getByText('Spawning subagent')).toBeInTheDocument();
expect(codeBlockSpy).toHaveBeenCalledWith(
- expect.objectContaining({ code: 'Spawning subagent' }),
+ expect.objectContaining({
+ code: 'Spawning subagent',
+ variant: 'compact',
+ highlight: false,
+ className: expect.stringContaining('bg-transparent'),
+ }),
);
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/activity-groups.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/activity-groups.client.test.ts
new file mode 100644
index 00000000..547a71a2
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/activity-groups.client.test.ts
@@ -0,0 +1,461 @@
+import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types';
+
+import type { TaskArtifact } from '@/types';
+
+import {
+ buildAcpActivityRenderBlocks,
+ isActivityCollapsibleBlock,
+} from '../activity-groups';
+import type {
+ AcpRenderBlock,
+ GroupedToolCallRenderBlock,
+} from '../render-blocks';
+import type { AcpToolResultUiMessage } from '../types';
+
+function textBlock(id: string, ts: number): AcpRenderBlock {
+ return {
+ kind: 'message',
+ msg: {
+ id,
+ ts,
+ role: 'assistant',
+ kind: 'text',
+ partial: false,
+ sessionId: 'session-1',
+ updateType: 'roomote_runtime.assistant_message',
+ text: id,
+ data: {},
+ },
+ };
+}
+
+function messageBlock(
+ id: string,
+ ts: number,
+ kind: 'reasoning' | 'todo_section' | 'task_cancelled',
+): AcpRenderBlock {
+ if (kind === 'todo_section') {
+ return {
+ kind: 'message',
+ msg: {
+ id,
+ ts,
+ role: 'assistant',
+ kind,
+ partial: false,
+ sessionId: 'session-1',
+ updateType: ACP_ENVELOPE_EVENT_TYPES.Plan,
+ text: id,
+ data: { todoId: id, content: id },
+ },
+ };
+ }
+
+ return {
+ kind: 'message',
+ msg: {
+ id,
+ ts,
+ role: kind === 'task_cancelled' ? 'system' : 'assistant',
+ kind,
+ partial: false,
+ sessionId: 'session-1',
+ updateType:
+ kind === 'task_cancelled'
+ ? ACP_ENVELOPE_EVENT_TYPES.TaskCancelled
+ : ACP_ENVELOPE_EVENT_TYPES.AssistantMessage,
+ text: id,
+ data: kind === 'task_cancelled' ? { sessionId: 'session-1' } : {},
+ },
+ };
+}
+
+function toolResultBlock(params: {
+ id: string;
+ ts: number;
+ toolName?: string;
+ output?: string;
+ kind?: string;
+}): AcpRenderBlock {
+ return {
+ kind: 'message',
+ msg: buildToolResult(params),
+ };
+}
+
+function buildToolResult(params: {
+ id: string;
+ ts: number;
+ toolName?: string;
+ output?: string;
+ kind?: string;
+}): AcpToolResultUiMessage {
+ return {
+ id: params.id,
+ ts: params.ts,
+ role: 'tool',
+ kind: 'tool_result',
+ partial: false,
+ sessionId: 'session-1',
+ updateType: 'roomote_runtime.tool_result',
+ text: params.output,
+ data: {
+ toolCallId: `call-${params.id}`,
+ kind: params.kind ?? 'mcp',
+ title: params.toolName ?? 'read_file',
+ status: 'completed',
+ isExecute: false,
+ isMcp: true,
+ mcpServerName: 'roomote',
+ mcpToolName: params.toolName ?? 'read_file',
+ serverName: 'roomote',
+ toolName: params.toolName ?? 'read_file',
+ command: null,
+ output: params.output ?? '',
+ exitCode: null,
+ },
+ };
+}
+
+function toolGroupBlock(params: {
+ id: string;
+ ts: number;
+ items: AcpToolResultUiMessage[];
+}): GroupedToolCallRenderBlock {
+ return {
+ kind: 'tool_group',
+ id: params.id,
+ ts: params.ts,
+ action: 'Exploring',
+ objectSummary: `${params.items.length} files`,
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
+ items: params.items.map((msg) => ({
+ msg,
+ objectLabel: msg.data.title ?? 'Tool',
+ groupKey: 'mcp:roomote:read_file',
+ displayKind: 'read',
+ stepKind: 'read',
+ })),
+ };
+}
+
+const visualProofArtifact: TaskArtifact = {
+ id: 'artifact-1',
+ path: 'tmp/proof.png',
+ version: 1,
+ artifactType: 'visual-proof',
+ contentType: 'image/png',
+ size: 123,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ thumbnailUrl: 'https://example.test/thumb.png',
+};
+
+describe('buildAcpActivityRenderBlocks', () => {
+ it('collapses one eligible activity block between text messages', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ textBlock('text-2', 19_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'activity_group',
+ 'message',
+ ]);
+ expect(entries[1]).toMatchObject({
+ kind: 'activity_group',
+ ts: 2_000,
+ endTs: 19_000,
+ });
+ });
+
+ it('keeps live partial reasoning and tools outside collapsed activity groups', () => {
+ const partialReasoning: AcpRenderBlock = {
+ kind: 'message',
+ msg: {
+ id: 'reasoning-partial',
+ ts: 2_000,
+ role: 'assistant',
+ kind: 'reasoning',
+ partial: true,
+ sessionId: 'session-1',
+ updateType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage,
+ text: 'thinking…',
+ data: {},
+ },
+ };
+
+ const inProgressTool: AcpRenderBlock = {
+ kind: 'message',
+ msg: {
+ id: 'tool-partial',
+ ts: 3_000,
+ role: 'tool',
+ kind: 'tool_call',
+ partial: true,
+ sessionId: 'session-1',
+ updateType: 'roomote_runtime.tool_call',
+ data: {
+ toolCallId: 'call-tool-partial',
+ kind: 'mcp',
+ title: 'read_file',
+ status: 'in_progress',
+ isExecute: false,
+ isRead: true,
+ isMcp: true,
+ mcpServerName: 'roomote',
+ mcpToolName: 'read_file',
+ serverName: 'roomote',
+ toolName: 'read_file',
+ command: null,
+ },
+ },
+ };
+
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ partialReasoning,
+ inProgressTool,
+ textBlock('text-2', 4_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ ]);
+ });
+
+ it('collapses mixed eligible activity blocks into one group', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ toolGroupBlock({
+ id: 'group-1',
+ ts: 4_000,
+ items: [
+ buildToolResult({ id: 'tool-1', ts: 4_000 }),
+ buildToolResult({ id: 'tool-2', ts: 5_000 }),
+ ],
+ }),
+ textBlock('text-2', 10_000),
+ ]);
+
+ expect(entries).toHaveLength(3);
+ expect(entries[1]?.kind).toBe('activity_group');
+
+ if (entries[1]?.kind !== 'activity_group') {
+ throw new Error('Expected activity group');
+ }
+
+ expect(entries[1].blocks.map((block) => block.kind)).toEqual([
+ 'message',
+ 'tool_group',
+ ]);
+ });
+
+ it('keeps todo section markers visible and starts a new activity boundary after them', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ messageBlock('todo-1', 3_000, 'todo_section'),
+ toolGroupBlock({
+ id: 'group-1',
+ ts: 4_000,
+ items: [
+ buildToolResult({ id: 'tool-1', ts: 4_000 }),
+ buildToolResult({ id: 'tool-2', ts: 5_000 }),
+ ],
+ }),
+ textBlock('text-2', 10_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ 'activity_group',
+ 'message',
+ ]);
+
+ expect(entries[3]).toMatchObject({
+ kind: 'activity_group',
+ ts: 4_000,
+ endTs: 10_000,
+ });
+ });
+
+ it('keeps task cancellation visible and uses it as a boundary', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ messageBlock('cancel-1', 3_000, 'task_cancelled'),
+ messageBlock('reasoning-2', 4_000, 'reasoning'),
+ textBlock('text-2', 5_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ ]);
+ });
+
+ it('keeps manage_artifacts rows visible and uses them as a boundary', () => {
+ const manageArtifacts = toolResultBlock({
+ id: 'artifact-tool',
+ ts: 3_000,
+ toolName: 'manage_artifacts',
+ });
+
+ expect(isActivityCollapsibleBlock(manageArtifacts)).toBe(false);
+
+ const entries = buildAcpActivityRenderBlocks([
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ manageArtifacts,
+ messageBlock('reasoning-2', 4_000, 'reasoning'),
+ textBlock('text-2', 5_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ ]);
+ });
+
+ it('keeps visual-proof preview rows visible and uses them as a boundary', () => {
+ const visualProof = toolResultBlock({
+ id: 'subagent-proof',
+ ts: 3_000,
+ toolName: 'subagent',
+ kind: 'subagent',
+ output:
+ 'Uploaded proof https://example.test/task/task-1/artifacts/tmp/proof.png?v=1',
+ });
+
+ if (
+ visualProof.kind !== 'message' ||
+ visualProof.msg.kind !== 'tool_result'
+ ) {
+ throw new Error('Expected tool result');
+ }
+
+ visualProof.msg.data = {
+ ...visualProof.msg.data,
+ isMcp: false,
+ isSubagentSpawn: true,
+ receiverThreadIds: [],
+ };
+
+ expect(isActivityCollapsibleBlock(visualProof, [visualProofArtifact])).toBe(
+ false,
+ );
+
+ const entries = buildAcpActivityRenderBlocks(
+ [
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ visualProof,
+ messageBlock('reasoning-2', 4_000, 'reasoning'),
+ textBlock('text-2', 5_000),
+ ],
+ { artifacts: [visualProofArtifact] },
+ );
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ 'message',
+ ]);
+ });
+
+ it('collapses leading eligible activity before the first text message', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ messageBlock('leading', 1_000, 'reasoning'),
+ textBlock('text-1', 2_000),
+ messageBlock('trailing', 3_000, 'reasoning'),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'activity_group',
+ 'message',
+ 'message',
+ ]);
+
+ expect(entries[0]).toMatchObject({
+ kind: 'activity_group',
+ ts: 1_000,
+ endTs: 2_000,
+ });
+ });
+
+ it('keeps leading todo section markers visible and starts a new activity boundary after them', () => {
+ const entries = buildAcpActivityRenderBlocks([
+ messageBlock('leading', 1_000, 'reasoning'),
+ messageBlock('todo-1', 1_500, 'todo_section'),
+ messageBlock('reasoning-2', 1_700, 'reasoning'),
+ textBlock('text-1', 2_000),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'activity_group',
+ 'message',
+ ]);
+
+ expect(entries[2]).toMatchObject({
+ kind: 'activity_group',
+ ts: 1_700,
+ endTs: 2_000,
+ });
+ });
+
+ it('collapses leading activity when an external session prompt provides the left text boundary', () => {
+ const entries = buildAcpActivityRenderBlocks(
+ [
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ textBlock('text-1', 10_000),
+ ],
+ { hasLeadingTextBoundary: true },
+ );
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'activity_group',
+ 'message',
+ ]);
+ expect(entries[0]).toMatchObject({
+ kind: 'activity_group',
+ ts: 2_000,
+ endTs: 10_000,
+ });
+ });
+
+ it('bypasses grouping in narration mode', () => {
+ const entries = buildAcpActivityRenderBlocks(
+ [
+ textBlock('text-1', 1_000),
+ messageBlock('reasoning-1', 2_000, 'reasoning'),
+ textBlock('text-2', 3_000),
+ ],
+ { displayMode: 'narration' },
+ );
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
+ ]);
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts
index dc111afa..9b754e5f 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts
@@ -387,7 +387,7 @@ describe('buildAcpRenderBlocks', () => {
'evaluator.ts',
'task-runs.ts',
]);
- expect(entries[0].items.map((item) => item.stepKind)).toEqual([
+ expect(entries[0].items.map((item) => item.displayKind)).toEqual([
'read',
'read',
'read',
@@ -453,7 +453,8 @@ describe('buildAcpRenderBlocks', () => {
});
it('keeps a stable group id and ts as results stream in', () => {
- // Before the results arrive, only the tool_call messages are present.
+ // Before either result arrives, fewer than two calls are complete so the
+ // run stays expanded as individual messages.
const streamingEntries = buildAcpRenderBlocks([
readFileToolCallMessage({
id: 'tool-1-call',
@@ -469,8 +470,13 @@ describe('buildAcpRenderBlocks', () => {
}),
]);
- // After the results arrive, the deduped items flip to tool_result, but the
- // group id/ts must stay anchored to the first message in the run.
+ expect(streamingEntries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ ]);
+
+ // After the results arrive, the run collapses. The group id/ts must stay
+ // anchored to the first message in the consecutive run.
const settledEntries = buildAcpRenderBlocks([
readFileToolCallMessage({
id: 'tool-1-call',
@@ -500,29 +506,19 @@ describe('buildAcpRenderBlocks', () => {
}),
]);
- if (
- streamingEntries[0]?.kind !== 'tool_group' ||
- settledEntries[0]?.kind !== 'tool_group'
- ) {
- throw new Error('Expected tool_group entries');
+ if (settledEntries[0]?.kind !== 'tool_group') {
+ throw new Error('Expected tool_group entry');
}
- expect(streamingEntries[0].id).toBe('tool-1-call');
- expect(streamingEntries[0].ts).toBe(1);
expect(settledEntries[0].id).toBe('tool-1-call');
expect(settledEntries[0].ts).toBe(1);
- // The representative items did flip from tool_call to tool_result.
- expect(streamingEntries[0].items.map((item) => item.msg.kind)).toEqual([
- 'tool_call',
- 'tool_call',
- ]);
expect(settledEntries[0].items.map((item) => item.msg.kind)).toEqual([
'tool_result',
'tool_result',
]);
});
- it('keeps an in-progress tool_call when no paired tool_result has arrived', () => {
+ it('does not collapse until the second same-type call completes', () => {
const entries = buildAcpRenderBlocks([
readFileToolMessage({
id: 'tool-1-result',
@@ -538,19 +534,47 @@ describe('buildAcpRenderBlocks', () => {
}),
]);
- expect(entries).toHaveLength(1);
- expect(entries[0]).toMatchObject({
- kind: 'tool_group',
- objectSummary: '2 files',
- });
+ expect(entries.map((entry) => entry.kind)).toEqual(['message', 'message']);
+ if (entries[0]?.kind !== 'message' || entries[1]?.kind !== 'message') {
+ throw new Error('Expected plain message entries');
+ }
+
+ expect(entries[0].msg.kind).toBe('tool_result');
+ expect(entries[1].msg.kind).toBe('tool_call');
+ });
+ it('appends later the third completed same-type call into the collapsed group', () => {
+ const entries = buildAcpRenderBlocks([
+ readFileToolMessage({
+ id: 'tool-1',
+ ts: 1,
+ title: 'Read server.ts',
+ text: 'server content',
+ }),
+ readFileToolMessage({
+ id: 'tool-2',
+ ts: 2,
+ title: 'Read evaluator.ts',
+ text: 'evaluator content',
+ }),
+ readFileToolMessage({
+ id: 'tool-3',
+ ts: 3,
+ title: 'Read task-runs.ts',
+ text: 'task runs content',
+ }),
+ ]);
+
+ expect(entries).toHaveLength(1);
if (entries[0]?.kind !== 'tool_group') {
throw new Error('Expected tool_group entry');
}
- expect(entries[0].items.map((item) => item.msg.kind)).toEqual([
- 'tool_result',
- 'tool_call',
+ expect(entries[0].objectSummary).toBe('3 files');
+ expect(entries[0].items.map((item) => item.msg.id)).toEqual([
+ 'tool-1',
+ 'tool-2',
+ 'tool-3',
]);
});
@@ -588,7 +612,7 @@ describe('buildAcpRenderBlocks', () => {
expect(entries[0].msg.data.title).toBe('Read server.ts');
});
- it('groups search, list, and read steps into one exploration block', () => {
+ it('does not mix different tool types into one collapsed group', () => {
const entries = buildAcpRenderBlocks([
searchFilesToolMessage({
id: 'tool-search',
@@ -607,43 +631,26 @@ describe('buildAcpRenderBlocks', () => {
}),
]);
- expect(entries).toHaveLength(1);
- expect(entries[0]).toMatchObject({
- kind: 'tool_group',
- action: 'Exploring',
- objectSummary: '1 search, 1 listing and 1 file',
- });
-
- if (entries[0]?.kind !== 'tool_group') {
- throw new Error('Expected tool_group entry');
- }
-
- expect(entries[0].items.map((item) => item.stepKind)).toEqual([
- 'search',
- 'list',
- 'read',
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'message',
+ 'message',
+ 'message',
]);
});
it('uses payload labels when grouped MCP items have no descriptive title', () => {
const entries = buildAcpRenderBlocks([
- searchFilesToolMessage({
- id: 'tool-search',
+ readFileToolMessage({
+ id: 'tool-read-1',
ts: 1,
title: null,
- payload: { query: 'Button.tsx' },
- }),
- listFilesToolMessage({
- id: 'tool-list',
- ts: 2,
- title: null,
- payload: { path: 'src/components' },
+ payload: { path: 'src/components/Button.tsx' },
}),
readFileToolMessage({
- id: 'tool-read',
- ts: 3,
+ id: 'tool-read-2',
+ ts: 2,
title: null,
- payload: { path: 'src/components/Button.tsx' },
+ payload: { path: 'src/components/Input.tsx' },
}),
]);
@@ -654,44 +661,32 @@ describe('buildAcpRenderBlocks', () => {
}
expect(entries[0].items.map((item) => item.objectLabel)).toEqual([
- 'Button.tsx',
- 'src/components',
'src/components/Button.tsx',
+ 'src/components/Input.tsx',
]);
});
it('uses nested rawInput arguments when grouped MCP items omit top-level labels', () => {
const entries = buildAcpRenderBlocks([
- searchFilesToolMessage({
- id: 'tool-search',
+ readFileToolMessage({
+ id: 'tool-read-1',
ts: 1,
title: null,
payload: {
rawInput: {
- tool: 'search_files',
- arguments: { query: 'Button.tsx' },
- },
- },
- }),
- listFilesToolMessage({
- id: 'tool-list',
- ts: 2,
- title: null,
- payload: {
- rawInput: {
- tool: 'list_files',
- arguments: { path: 'src/components' },
+ tool: 'read_file',
+ arguments: { path: 'src/components/Button.tsx' },
},
},
}),
readFileToolMessage({
- id: 'tool-read',
- ts: 3,
+ id: 'tool-read-2',
+ ts: 2,
title: null,
payload: {
rawInput: {
tool: 'read_file',
- arguments: { path: 'src/components/Button.tsx' },
+ arguments: { path: 'src/components/Input.tsx' },
},
},
}),
@@ -704,9 +699,8 @@ describe('buildAcpRenderBlocks', () => {
}
expect(entries[0].items.map((item) => item.objectLabel)).toEqual([
- 'Button.tsx',
- 'src/components',
'src/components/Button.tsx',
+ 'src/components/Input.tsx',
]);
});
@@ -742,7 +736,7 @@ describe('buildAcpRenderBlocks', () => {
]);
});
- it('does not group execute tools even when commands look like exploration', () => {
+ it('groups consecutive completed shell commands into one block', () => {
const entries = buildAcpRenderBlocks([
executeExplorationToolMessage({
id: 'tool-search',
@@ -758,7 +752,255 @@ describe('buildAcpRenderBlocks', () => {
}),
]);
- expect(entries.map((entry) => entry.kind)).toEqual(['message', 'message']);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ kind: 'tool_group',
+ action: 'Ran',
+ objectSummary: '2 commands',
+ displayKind: 'execute',
+ });
+
+ if (entries[0]?.kind !== 'tool_group') {
+ throw new Error('Expected tool_group entry');
+ }
+
+ expect(entries[0].items.map((item) => item.objectLabel)).toEqual([
+ 'rg Button src/components',
+ 'cat src/components/Button.tsx',
+ ]);
+ });
+
+ it('does not mix shell commands with other tool types', () => {
+ const entries = buildAcpRenderBlocks([
+ executeExplorationToolMessage({
+ id: 'tool-execute-1',
+ ts: 1,
+ title: 'Run ls',
+ command: 'ls',
+ }),
+ executeExplorationToolMessage({
+ id: 'tool-execute-2',
+ ts: 2,
+ title: 'Run pwd',
+ command: 'pwd',
+ }),
+ readFileToolMessage({
+ id: 'tool-read',
+ ts: 3,
+ title: 'Read package.json',
+ }),
+ ]);
+
+ expect(entries.map((entry) => entry.kind)).toEqual([
+ 'tool_group',
+ 'message',
+ ]);
+ });
+
+ it('uses lowercase plural-friendly labels for generic MCP tool groups', () => {
+ const entries = buildAcpRenderBlocks([
+ explorationToolMessage({
+ id: 'tool-1',
+ ts: 1,
+ title: null,
+ kind: 'mcp',
+ toolName: 'get_issue',
+ }),
+ explorationToolMessage({
+ id: 'tool-2',
+ ts: 2,
+ title: null,
+ kind: 'mcp',
+ toolName: 'get_issue',
+ }),
+ ]);
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ kind: 'tool_group',
+ action: 'Used',
+ objectSummary: '2 get issue calls',
+ displayKind: 'tool',
+ });
+ });
+
+ it('groups edit tools with edit-specific summary labels', () => {
+ const entries = buildAcpRenderBlocks([
+ explorationToolMessage({
+ id: 'tool-edit-1',
+ ts: 1,
+ title: 'Edit src/a.ts',
+ mcp: false,
+ kind: 'edit',
+ }),
+ explorationToolMessage({
+ id: 'tool-edit-2',
+ ts: 2,
+ title: 'Edit src/b.ts',
+ mcp: false,
+ kind: 'edit',
+ }),
+ ]);
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ kind: 'tool_group',
+ action: 'Edited',
+ objectSummary: '2 files',
+ displayKind: 'edit',
+ });
+ });
+
+ it('dedupes call/result pairs without a toolCallId via payload signature', () => {
+ const callWithoutId = {
+ ...readFileToolCallMessage({
+ id: 'tool-1-call',
+ ts: 1,
+ title: 'Read server.ts',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolCallMessage({
+ id: 'tool-1-call',
+ ts: 1,
+ title: 'Read server.ts',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolCallUiMessage;
+
+ const resultWithoutId = {
+ ...readFileToolMessage({
+ id: 'tool-1-result',
+ ts: 2,
+ title: 'Read server.ts',
+ text: 'server content',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolMessage({
+ id: 'tool-1-result',
+ ts: 2,
+ title: 'Read server.ts',
+ text: 'server content',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolResultUiMessage;
+
+ const call2WithoutId = {
+ ...readFileToolCallMessage({
+ id: 'tool-2-call',
+ ts: 3,
+ title: 'Read evaluator.ts',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolCallMessage({
+ id: 'tool-2-call',
+ ts: 3,
+ title: 'Read evaluator.ts',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolCallUiMessage;
+
+ const result2WithoutId = {
+ ...readFileToolMessage({
+ id: 'tool-2-result',
+ ts: 4,
+ title: 'Read evaluator.ts',
+ text: 'evaluator content',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolMessage({
+ id: 'tool-2-result',
+ ts: 4,
+ title: 'Read evaluator.ts',
+ text: 'evaluator content',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolResultUiMessage;
+
+ const entries = buildAcpRenderBlocks([
+ callWithoutId,
+ resultWithoutId,
+ call2WithoutId,
+ result2WithoutId,
+ ]);
+
+ expect(entries).toHaveLength(1);
+ if (entries[0]?.kind !== 'tool_group') {
+ throw new Error('Expected tool_group entry');
+ }
+
+ expect(entries[0].items).toHaveLength(2);
+ expect(entries[0].items.map((item) => item.msg.kind)).toEqual([
+ 'tool_result',
+ 'tool_result',
+ ]);
+ expect(entries[0].objectSummary).toBe('2 files');
+ });
+
+ it('keeps distinct no-toolCallId results with the same signature separate', () => {
+ const firstRead = {
+ ...readFileToolMessage({
+ id: 'tool-1-result',
+ ts: 1,
+ title: 'Read server.ts',
+ text: 'first pass',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolMessage({
+ id: 'tool-1-result',
+ ts: 1,
+ title: 'Read server.ts',
+ text: 'first pass',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolResultUiMessage;
+
+ const secondRead = {
+ ...readFileToolMessage({
+ id: 'tool-2-result',
+ ts: 2,
+ title: 'Read server.ts',
+ text: 'second pass',
+ toolCallId: '',
+ }),
+ data: {
+ ...readFileToolMessage({
+ id: 'tool-2-result',
+ ts: 2,
+ title: 'Read server.ts',
+ text: 'second pass',
+ toolCallId: '',
+ }).data,
+ toolCallId: null,
+ },
+ } as AcpToolResultUiMessage;
+
+ const entries = buildAcpRenderBlocks([firstRead, secondRead]);
+
+ expect(entries).toHaveLength(1);
+ if (entries[0]?.kind !== 'tool_group') {
+ throw new Error('Expected tool_group entry');
+ }
+
+ expect(entries[0].items).toHaveLength(2);
+ expect(entries[0].items.map((item) => item.msg.id)).toEqual([
+ 'tool-1-result',
+ 'tool-2-result',
+ ]);
});
it('does not group across non-tool messages', () => {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts
new file mode 100644
index 00000000..399335b9
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts
@@ -0,0 +1,202 @@
+import type { TaskArtifact } from '@/types';
+
+import type {
+ AcpToolCallUiMessage,
+ AcpToolResultUiMessage,
+ AcpUiMessage,
+} from './types';
+import type { AcpRenderBlock } from './render-blocks';
+import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result';
+
+const COLLAPSIBLE_ACP_MESSAGE_KINDS = [
+ 'reasoning',
+ 'tool_call',
+ 'tool_result',
+] as const;
+
+const COLLAPSIBLE_ACP_MESSAGE_KIND_SET = new Set(
+ COLLAPSIBLE_ACP_MESSAGE_KINDS,
+);
+
+const MANAGE_ARTIFACTS_TOOL_NAME = 'manage_artifacts';
+
+export interface AcpActivityGroupRenderBlock {
+ kind: 'activity_group';
+ id: string;
+ ts: number;
+ endTs: number;
+ blocks: AcpRenderBlock[];
+}
+
+export type AcpConversationRenderBlock =
+ | AcpRenderBlock
+ | AcpActivityGroupRenderBlock;
+
+interface BuildAcpActivityRenderBlocksOptions {
+ artifacts?: readonly TaskArtifact[] | null;
+ displayMode?: 'default' | 'narration';
+ hasLeadingTextBoundary?: boolean;
+ collapseLeadingActivity?: boolean;
+}
+
+function isToolMessage(
+ msg: AcpUiMessage,
+): msg is AcpToolCallUiMessage | AcpToolResultUiMessage {
+ return msg.kind === 'tool_call' || msg.kind === 'tool_result';
+}
+
+function getBlockId(block: AcpRenderBlock): string {
+ return block.kind === 'tool_group' ? block.id : block.msg.id;
+}
+
+function getBlockTs(block: AcpRenderBlock): number {
+ return block.kind === 'tool_group' ? block.ts : block.msg.ts;
+}
+
+function isTextBoundaryBlock(block: AcpRenderBlock): boolean {
+ return block.kind === 'message' && block.msg.kind === 'text';
+}
+
+function isProgressBoundaryBlock(block: AcpRenderBlock): boolean {
+ return block.kind === 'message' && block.msg.kind === 'todo_section';
+}
+
+function getToolName(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+): string | null {
+ const rawName = msg.data.toolName ?? msg.data.mcpToolName;
+ const normalized = rawName?.trim().toLowerCase();
+
+ return normalized && normalized.length > 0 ? normalized : null;
+}
+
+function isArtifactToolMessage(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+ artifacts: readonly TaskArtifact[] | null | undefined,
+): boolean {
+ if (getToolName(msg) === MANAGE_ARTIFACTS_TOOL_NAME) {
+ return true;
+ }
+
+ return resolveVisualProofMediaForToolMessage(msg, artifacts).length > 0;
+}
+
+function isLivePartialBlock(block: AcpRenderBlock): boolean {
+ if (block.kind === 'tool_group') {
+ return block.items.some(
+ (item) =>
+ item.msg.partial === true || item.msg.data.status === 'in_progress',
+ );
+ }
+
+ if (block.msg.partial === true) {
+ return true;
+ }
+
+ return isToolMessage(block.msg) && block.msg.data.status === 'in_progress';
+}
+
+export function isActivityCollapsibleBlock(
+ block: AcpRenderBlock,
+ artifacts?: readonly TaskArtifact[] | null,
+): boolean {
+ // Keep in-flight reasoning/tool rows outside default-closed groups so current
+ // activity stays visible without a manual expand.
+ if (isLivePartialBlock(block)) {
+ return false;
+ }
+
+ if (block.kind === 'tool_group') {
+ return !block.items.some((item) =>
+ isArtifactToolMessage(item.msg, artifacts),
+ );
+ }
+
+ const { msg } = block;
+
+ if (!COLLAPSIBLE_ACP_MESSAGE_KIND_SET.has(msg.kind)) {
+ return false;
+ }
+
+ if (isToolMessage(msg) && isArtifactToolMessage(msg, artifacts)) {
+ return false;
+ }
+
+ return true;
+}
+
+export function buildAcpActivityRenderBlocks(
+ blocks: AcpRenderBlock[],
+ options: BuildAcpActivityRenderBlocksOptions = {},
+): AcpConversationRenderBlock[] {
+ if (options.displayMode === 'narration') {
+ return blocks;
+ }
+
+ const groupedBlocks: AcpConversationRenderBlock[] = [];
+ let cursor = 0;
+ let hasLeftTextBoundary =
+ options.hasLeadingTextBoundary === true ||
+ options.collapseLeadingActivity !== false;
+
+ while (cursor < blocks.length) {
+ const current = blocks[cursor]!;
+
+ if (isTextBoundaryBlock(current)) {
+ groupedBlocks.push(current);
+ hasLeftTextBoundary = true;
+ cursor += 1;
+ continue;
+ }
+
+ if (isProgressBoundaryBlock(current)) {
+ groupedBlocks.push(current);
+ hasLeftTextBoundary = true;
+ cursor += 1;
+ continue;
+ }
+
+ if (
+ !hasLeftTextBoundary ||
+ !isActivityCollapsibleBlock(current, options.artifacts)
+ ) {
+ groupedBlocks.push(current);
+ hasLeftTextBoundary = false;
+ cursor += 1;
+ continue;
+ }
+
+ const activityStart = cursor;
+ let activityEnd = activityStart;
+
+ while (
+ activityEnd < blocks.length &&
+ isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts)
+ ) {
+ activityEnd += 1;
+ }
+
+ const activityBlocks = blocks.slice(activityStart, activityEnd);
+ const next = blocks[activityEnd];
+
+ if (activityBlocks.length > 0 && next && isTextBoundaryBlock(next)) {
+ const firstActivity = activityBlocks[0]!;
+
+ groupedBlocks.push({
+ kind: 'activity_group',
+ id: `activity-${getBlockId(firstActivity)}`,
+ ts: getBlockTs(firstActivity),
+ endTs: getBlockTs(next),
+ blocks: activityBlocks,
+ });
+ cursor = activityEnd;
+ continue;
+ }
+
+ groupedBlocks.push(...activityBlocks);
+ hasLeftTextBoundary = false;
+ cursor = activityEnd;
+ }
+
+ return groupedBlocks;
+}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/index.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/index.ts
index d6749c31..9f8ff22f 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/index.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/index.ts
@@ -2,4 +2,5 @@ export * from './types';
export * from './AcpMessageItem';
export * from './AcpTextMessage';
export * from './AcpGroupedToolMessage';
+export * from './AcpActivityGroupMessage';
export * from './AcpTodoSectionMessage';
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts
index 71898c02..511621c1 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts
@@ -17,10 +17,17 @@ import type {
import {
isSubagentSpawnRowMessage,
isSubagentToolMessage,
+ isSubagentToolPayload,
} from './subagent-tool';
export type ExplorationStepKind = 'list' | 'read' | 'search';
+export type GroupedToolDisplayKind =
+ | ExplorationStepKind
+ | 'execute'
+ | 'edit'
+ | 'tool';
+
const EXPLORATION_TOOL_NAMES: Record> = {
search: new Set(['search', 'search_file', 'search_files']),
list: new Set(['glob', 'list', 'list_dir', 'list_directory', 'list_files']),
@@ -52,10 +59,33 @@ const STEP_KIND_DATA_KEYS: Record = {
read: ['path', 'filePath', 'file_path', 'filename', 'file', 'uri'],
};
+const GENERIC_TOOL_DATA_KEYS = [
+ 'path',
+ 'filePath',
+ 'file_path',
+ 'filename',
+ 'file',
+ 'uri',
+ 'query',
+ 'pattern',
+ 'search',
+ 'directory',
+ 'dir',
+ 'cwd',
+ 'target',
+ 'command',
+];
+
+/** Minimum completed/failed same-type tool invocations before collapsing. */
+const TOOL_GROUP_MIN_SETTLED = 2;
+
export interface GroupedToolCallItem {
msg: AcpToolCallUiMessage | AcpToolResultUiMessage;
objectLabel: string;
- stepKind: ExplorationStepKind;
+ groupKey: string;
+ displayKind: GroupedToolDisplayKind;
+ /** @deprecated Prefer displayKind; kept for exploration-item icons. */
+ stepKind: ExplorationStepKind | null;
}
interface SingleAcpRenderBlock {
@@ -70,6 +100,8 @@ export interface GroupedToolCallRenderBlock {
ts: number;
action: string;
objectSummary: string;
+ groupKey: string;
+ displayKind: GroupedToolDisplayKind;
items: GroupedToolCallItem[];
}
@@ -124,7 +156,7 @@ type HiddenBehavior = 'boundary' | 'transparent';
type MessageRenderState =
| {
visibility: 'render';
- stepKind: ExplorationStepKind | null;
+ groupKey: string | null;
}
| {
visibility: 'hidden';
@@ -254,6 +286,17 @@ function extractLabelFromToolData(
return extractStringByKeys(argumentsRecord as Record, keys);
}
+function isExecuteToolMessage(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+): boolean {
+ const data = msg.data as unknown as Record;
+ return (
+ msg.data.kind === 'execute' ||
+ msg.data.kind === 'execute_command' ||
+ data.isExecute === true
+ );
+}
+
function resolveExplorationStepKind(
msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
): ExplorationStepKind | null {
@@ -271,6 +314,10 @@ function resolveExplorationStepKind(
return 'search';
}
+ if (msg.data.kind === 'list') {
+ return 'list';
+ }
+
if (msg.data.kind === 'read') {
return 'read';
}
@@ -278,31 +325,96 @@ function resolveExplorationStepKind(
return null;
}
-function formatHumanList(values: string[]): string {
- if (values.length === 0) {
- return '';
+/**
+ * Stable identity for consecutive same-type collapsing. Different tools never
+ * share a key, even when both are MCP exploration-style helpers.
+ */
+function resolveToolGroupKey(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+): string | null {
+ // Subagent rows own nested child-session output and should stay standalone.
+ if (isSubagentToolPayload(msg.data)) {
+ return null;
+ }
+
+ if (isExecuteToolMessage(msg)) {
+ return 'execute';
+ }
+
+ const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '')
+ .trim()
+ .toLowerCase();
+ const serverName = (msg.data.serverName ?? msg.data.mcpServerName ?? '')
+ .trim()
+ .toLowerCase();
+
+ if (toolName) {
+ return serverName ? `mcp:${serverName}:${toolName}` : `tool:${toolName}`;
+ }
+
+ const kind = (msg.data.kind ?? '').trim().toLowerCase();
+
+ if (kind && kind !== 'mcp') {
+ return `kind:${kind}`;
+ }
+
+ return null;
+}
+
+function resolveGroupedToolDisplayKind(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+ groupKey: string,
+): GroupedToolDisplayKind {
+ if (groupKey === 'execute' || isExecuteToolMessage(msg)) {
+ return 'execute';
}
- if (values.length === 1) {
- return values[0]!;
+ const explorationStep = resolveExplorationStepKind(msg);
+
+ if (explorationStep) {
+ return explorationStep;
}
- if (values.length === 2) {
- return `${values[0]} and ${values[1]}`;
+ if (msg.data.kind === 'edit') {
+ return 'edit';
}
- const head = values.slice(0, -1).join(', ');
- const tail = values[values.length - 1];
- return `${head} and ${tail}`;
+ return 'tool';
}
-const TITLE_PREFIX_RE = /^(?:search|read|list|find)\s+(.+)$/i;
+function isSettledToolMessage(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+): boolean {
+ if (msg.partial === true) {
+ return false;
+ }
+
+ return msg.data.status === 'completed' || msg.data.status === 'failed';
+}
+
+function formatGenericToolLabel(value: string): string {
+ return value.split(/[_-]+/).filter(Boolean).join(' ').toLowerCase();
+}
+
+const TITLE_PREFIX_RE =
+ /^(?:search|read|list|find|run|using|used|ran|running)\s+(.+)$/i;
function extractObjectLabel(
msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
- stepKind: ExplorationStepKind,
+ displayKind: GroupedToolDisplayKind,
): string {
+ if (displayKind === 'execute') {
+ const command = msg.data.command?.trim();
+ if (command) {
+ return command;
+ }
+ }
+
const title = msg.data.title?.trim();
+ const dataKeys =
+ displayKind === 'search' || displayKind === 'list' || displayKind === 'read'
+ ? STEP_KIND_DATA_KEYS[displayKind]
+ : GENERIC_TOOL_DATA_KEYS;
if (title) {
const match = TITLE_PREFIX_RE.exec(title);
@@ -311,91 +423,159 @@ function extractObjectLabel(
return match[1].trim();
}
- const payloadLabel = extractLabelFromToolData(
- msg.data,
- STEP_KIND_DATA_KEYS[stepKind],
- );
+ const payloadLabel = extractLabelFromToolData(msg.data, dataKeys);
return payloadLabel ?? title;
}
- return (
- extractLabelFromToolData(msg.data, STEP_KIND_DATA_KEYS[stepKind]) ?? 'Tool'
- );
+ return extractLabelFromToolData(msg.data, dataKeys) ?? 'Tool';
}
-function summarizeGroupObject(items: GroupedToolCallItem[]): string {
- const counts = new Map();
+function summarizeSameTypeGroup(
+ items: GroupedToolCallItem[],
+ displayKind: GroupedToolDisplayKind,
+ groupKey: string,
+): { action: string; objectSummary: string } {
+ const count = items.length;
- for (const item of items) {
- counts.set(item.stepKind, (counts.get(item.stepKind) ?? 0) + 1);
+ if (displayKind === 'execute') {
+ return {
+ action: 'Ran',
+ objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`,
+ };
+ }
+
+ if (
+ displayKind === 'search' ||
+ displayKind === 'list' ||
+ displayKind === 'read'
+ ) {
+ const labels = STEP_KIND_LABELS[displayKind];
+ return {
+ action: 'Exploring',
+ objectSummary: `${count} ${count === 1 ? labels.singular : labels.plural}`,
+ };
}
- const summaryParts = STEP_KIND_ORDER.flatMap((stepKind) => {
- const count = counts.get(stepKind) ?? 0;
+ if (displayKind === 'edit') {
+ return {
+ action: 'Edited',
+ objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`,
+ };
+ }
- if (count === 0) {
- return [];
- }
+ const toolNameMatch = /^(?:mcp:[^:]+:|tool:)(.+)$/.exec(groupKey);
+ const toolLabel = toolNameMatch?.[1]
+ ? formatGenericToolLabel(toolNameMatch[1])
+ : null;
- const labels = STEP_KIND_LABELS[stepKind];
- return [`${count} ${count === 1 ? labels.singular : labels.plural}`];
- });
+ if (toolLabel) {
+ return {
+ action: 'Used',
+ objectSummary:
+ count === 1 ? `1 ${toolLabel}` : `${count} ${toolLabel} calls`,
+ };
+ }
+
+ return {
+ action: 'Used',
+ objectSummary: `${count} ${count === 1 ? 'tool' : 'tools'}`,
+ };
+}
+
+function buildGroupedToolItem(
+ msg: AcpToolCallUiMessage | AcpToolResultUiMessage,
+ groupKey: string,
+): GroupedToolCallItem {
+ const displayKind = resolveGroupedToolDisplayKind(msg, groupKey);
+ const stepKind =
+ displayKind === 'search' || displayKind === 'list' || displayKind === 'read'
+ ? displayKind
+ : null;
- return formatHumanList(summaryParts) || `${items.length} exploration steps`;
+ return {
+ msg,
+ groupKey,
+ displayKind,
+ stepKind,
+ objectLabel: extractObjectLabel(msg, displayKind),
+ };
}
/**
* Collapse the paired `tool_call` and `tool_result` messages for the same
- * invocation into a single group item so the exploration summary counts each
+ * invocation into a single group item so the collapsed summary counts each
* invocation once instead of twice. The `tool_result` is preferred when both
* are present because it carries the populated title and output; the
* `tool_call` is kept as a fallback for in-progress invocations that have not
- * yet produced a result. Items without a usable `toolCallId` are kept as-is.
+ * yet produced a result.
+ *
+ * Items with a `toolCallId` are matched globally by that id. Items without one
+ * only merge a consecutive `tool_call` + `tool_result` pair that share the same
+ * payload signature, so two legitimate repeated operations stay distinct.
*/
function dedupeGroupItemsByToolCallId(
items: GroupedToolCallItem[],
): GroupedToolCallItem[] {
- const chosen = new Map();
+ const preferredByToolCallId = new Map();
for (const item of items) {
- const key = extractToolCallIdKey(item.msg.data.toolCallId);
+ const toolCallId = extractToolCallIdKey(item.msg.data.toolCallId);
- if (key === null) {
+ if (!toolCallId) {
continue;
}
- const existing = chosen.get(key);
+ const existing = preferredByToolCallId.get(toolCallId);
if (
!existing ||
(item.msg.kind === 'tool_result' && existing.msg.kind !== 'tool_result')
) {
- chosen.set(key, item);
+ preferredByToolCallId.set(toolCallId, item);
}
}
- if (chosen.size === 0) {
- return items;
- }
-
- const emittedKeys = new Set();
const result: GroupedToolCallItem[] = [];
+ const emittedToolCallIds = new Set();
+ let index = 0;
- for (const item of items) {
- const key = extractToolCallIdKey(item.msg.data.toolCallId);
+ while (index < items.length) {
+ const item = items[index]!;
+ const toolCallId = extractToolCallIdKey(item.msg.data.toolCallId);
- if (key === null) {
- result.push(item);
+ if (toolCallId) {
+ if (!emittedToolCallIds.has(toolCallId)) {
+ emittedToolCallIds.add(toolCallId);
+ result.push(preferredByToolCallId.get(toolCallId)!);
+ }
+
+ index += 1;
continue;
}
- if (emittedKeys.has(key)) {
+ const next = items[index + 1];
+ const signature = extractPayloadSignature(item);
+ const nextToolCallId = next
+ ? extractToolCallIdKey(next.msg.data.toolCallId)
+ : null;
+
+ if (
+ next &&
+ nextToolCallId === null &&
+ item.msg.kind === 'tool_call' &&
+ next.msg.kind === 'tool_result' &&
+ signature !== null &&
+ signature === extractPayloadSignature(next)
+ ) {
+ // Prefer the settled result for a single unpaired call/result stream.
+ result.push(next);
+ index += 2;
continue;
}
- emittedKeys.add(key);
- result.push(chosen.get(key)!);
+ result.push(item);
+ index += 1;
}
return result;
@@ -407,6 +587,18 @@ function extractToolCallIdKey(toolCallId: unknown): string | null {
: null;
}
+function extractPayloadSignature(item: GroupedToolCallItem): string | null {
+ const title = item.msg.data.title?.trim() ?? '';
+ const command = item.msg.data.command?.trim() ?? '';
+ const label = item.objectLabel.trim();
+
+ if (!title && !command && !label) {
+ return null;
+ }
+
+ return `${item.groupKey}|${title}|${command}|${label}`;
+}
+
function isEmptyCompletedTextMessage(msg: AcpUiMessage): boolean {
return (
msg.kind === 'text' &&
@@ -557,13 +749,13 @@ function resolveMessageRenderState(
if (!isToolMessage(msg)) {
return {
visibility: 'render',
- stepKind: null,
+ groupKey: null,
};
}
return {
visibility: 'render',
- stepKind: resolveExplorationStepKind(msg),
+ groupKey: resolveToolGroupKey(msg),
};
}
@@ -598,6 +790,38 @@ function buildAcpRenderBlocksInScope(
const blocks: AcpRenderBlock[] = [];
let cursor = 0;
+ const pushSingleMessage = (msg: AcpUiMessage): boolean => {
+ const currentMessage = dedupeRenderableMessageImages(msg, seenImageUris);
+
+ if (isEmptyCompletedTextMessage(currentMessage)) {
+ return false;
+ }
+
+ state.hasRenderedVisibleBlock = true;
+ const childMessages =
+ subagentChildSessionLookup.parentMessageToChildMessages.get(
+ currentMessage.id,
+ ) ?? [];
+ const childBlocks =
+ childMessages.length > 0
+ ? buildAcpRenderBlocksInScope(
+ childMessages,
+ options,
+ subagentChildSessionLookup,
+ seenImageUris,
+ state,
+ currentMessage.id,
+ )
+ : undefined;
+
+ blocks.push({
+ kind: 'message',
+ msg: currentMessage,
+ ...(childBlocks?.length ? { childBlocks } : {}),
+ });
+ return true;
+ };
+
while (cursor < messages.length) {
const current = messages[cursor]!;
const currentParentMessageId = current.sessionId
@@ -628,49 +852,15 @@ function buildAcpRenderBlocksInScope(
continue;
}
- if (!currentState.stepKind || !isToolMessage(current)) {
- const currentMessage = dedupeRenderableMessageImages(
- current,
- seenImageUris,
- );
-
- if (isEmptyCompletedTextMessage(currentMessage)) {
- cursor += 1;
- continue;
- }
-
- state.hasRenderedVisibleBlock = true;
- const childMessages =
- subagentChildSessionLookup.parentMessageToChildMessages.get(
- current.id,
- ) ?? [];
- const childBlocks =
- childMessages.length > 0
- ? buildAcpRenderBlocksInScope(
- childMessages,
- options,
- subagentChildSessionLookup,
- seenImageUris,
- state,
- current.id,
- )
- : undefined;
-
- blocks.push({
- kind: 'message',
- msg: currentMessage,
- ...(childBlocks?.length ? { childBlocks } : {}),
- });
+ if (!currentState.groupKey || !isToolMessage(current)) {
+ pushSingleMessage(current);
cursor += 1;
continue;
}
+ const runGroupKey = currentState.groupKey;
const items: GroupedToolCallItem[] = [
- {
- msg: current,
- objectLabel: extractObjectLabel(current, currentState.stepKind),
- stepKind: currentState.stepKind,
- },
+ buildGroupedToolItem(current, runGroupKey),
];
let runCursor = cursor + 1;
@@ -702,60 +892,58 @@ function buildAcpRenderBlocksInScope(
continue;
}
- if (!nextState.stepKind || !isToolMessage(next)) {
+ // Only collapse identical tool types consecutively.
+ if (
+ !nextState.groupKey ||
+ nextState.groupKey !== runGroupKey ||
+ !isToolMessage(next)
+ ) {
break;
}
- items.push({
- msg: next,
- objectLabel: extractObjectLabel(next, nextState.stepKind),
- stepKind: nextState.stepKind,
- });
+ items.push(buildGroupedToolItem(next, nextState.groupKey));
runCursor += 1;
}
if (items.length < 2) {
- const currentMessage = dedupeRenderableMessageImages(
- current,
- seenImageUris,
- );
-
- if (isEmptyCompletedTextMessage(currentMessage)) {
- cursor += 1;
- continue;
- }
-
- blocks.push({ kind: 'message', msg: currentMessage });
- state.hasRenderedVisibleBlock = true;
+ pushSingleMessage(current);
cursor += 1;
continue;
}
const dedupedItems = dedupeGroupItemsByToolCallId(items);
-
- if (dedupedItems.length < 2) {
- const representativeMessage = dedupeRenderableMessageImages(
- dedupedItems[0]!.msg,
- seenImageUris,
- );
-
- if (isEmptyCompletedTextMessage(representativeMessage)) {
- cursor += 1;
- continue;
+ const settledCount = dedupedItems.filter((item) =>
+ isSettledToolMessage(item.msg),
+ ).length;
+
+ // Keep individuals until the second same-type call has completed. Once the
+ // threshold is met, the whole consecutive same-type run collapses — including
+ // trailing in-progress invocations so later completions append into the same
+ // multi-call entry instead of spawning a new separate row.
+ if (dedupedItems.length < 2 || settledCount < TOOL_GROUP_MIN_SETTLED) {
+ for (const item of dedupedItems) {
+ pushSingleMessage(item.msg);
}
- blocks.push({ kind: 'message', msg: representativeMessage });
- state.hasRenderedVisibleBlock = true;
cursor = runCursor;
continue;
}
+ const displayKind = dedupedItems[0]!.displayKind;
+ const summary = summarizeSameTypeGroup(
+ dedupedItems,
+ displayKind,
+ runGroupKey,
+ );
+
blocks.push({
kind: 'tool_group',
id: items[0]!.msg.id,
ts: items[0]!.msg.ts,
- action: 'Exploring',
- objectSummary: summarizeGroupObject(dedupedItems),
+ action: summary.action,
+ objectSummary: summary.objectSummary,
+ groupKey: runGroupKey,
+ displayKind,
items: dedupedItems,
});
state.hasRenderedVisibleBlock = true;
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx
index 4fdbdd19..2e5b611b 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx
@@ -651,7 +651,7 @@ export const PromptInput = forwardRef(
);
return (
-
+
-
+ {icon ?? (
+
+ )}
);
@@ -112,6 +116,8 @@ type TaskPromptInputProps = {
submitDisabledReason?: string;
/** When true, submit on Cmd/Ctrl+Enter instead of plain Enter. */
submitWithMetaKey?: boolean;
+ submitIcon?: ReactNode;
+ surface?: 'default' | 'embedded';
};
export function TaskPromptInput({
@@ -127,6 +133,8 @@ export function TaskPromptInput({
suggestion,
submitDisabledReason,
submitWithMetaKey = true,
+ submitIcon,
+ surface = 'default',
}: TaskPromptInputProps) {
const voiceDictation = useVoiceDictation({
onTranscript: (text) => onPromptTextChange(text),
@@ -137,8 +145,12 @@ export function TaskPromptInput({
return (
onPromptTextChange(e.target.value)}
/>
-
+
@@ -187,6 +201,7 @@ export function TaskPromptInput({
diff --git a/knip.ts b/knip.ts
index cc01d0e6..621d61cc 100644
--- a/knip.ts
+++ b/knip.ts
@@ -40,8 +40,6 @@ const config: KnipConfig = {
},
'apps/preview-proxy': {
project: ['src/**/*.ts'],
- // Loaded by Pino transport resolution from the target string in logger.ts.
- ignoreDependencies: ['pino-pretty'],
},
'apps/web': {
entry: [