Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7434e8c
feat(extension): keep agent tool image bytes
iscekic Aug 6, 2026
7017c77
refactor(extension): share the agent tool panel
iscekic Aug 6, 2026
35e866d
feat(extension): render agent conversations with browser renderer
iscekic Aug 6, 2026
8267598
feat(extension): send messages during agent runs
iscekic Aug 6, 2026
c8228f5
test(extension): cover agent conversation parity
iscekic Aug 6, 2026
169c03c
fix(extension): skip synthetic snapshot progress
iscekic Aug 6, 2026
eaa8652
test(extension): stabilize live agent reopen flow
iscekic Aug 6, 2026
869fe33
fix(extension): restore live agent transcript history
iscekic Aug 6, 2026
07bfd76
fix(extension): retry active agent history reads
iscekic Aug 7, 2026
bb57c23
fix(extension): wait for active agent history
iscekic Aug 7, 2026
cb69786
chore(extension): trace active history replay
iscekic Aug 7, 2026
9c69197
chore(extension): remove replay diagnostics
iscekic Aug 7, 2026
6d7bc0b
Merge remote-tracking branch 'origin/main' into ext-agents-parity-3a50
iscekic Aug 7, 2026
cd89788
fix(extension): recover active history after liveness miss
iscekic Aug 7, 2026
84b5ffb
fix(extension): forward session event watermark
iscekic Aug 7, 2026
9c35b2f
fix(extension): retry stale idle agent history
iscekic Aug 7, 2026
2db9f79
test(extension): stabilize live history reopen
iscekic Aug 7, 2026
5e38da8
fix(extension): seed mocked agent history
iscekic Aug 7, 2026
604240a
fix(extension): preserve agent working state
iscekic Aug 7, 2026
4160d52
fix(extension): share agent working state
iscekic Aug 7, 2026
31a05a4
fix(extension): recover from liveness probe errors
iscekic Aug 7, 2026
13875fd
fix(extension): unblock active session history replay
iscekic Aug 7, 2026
bfbdc4c
fix(extension): retry active history without replay watermark
iscekic Aug 7, 2026
e6fa76b
refactor(extension): simplify agents conversation diff
iscekic Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable max-expects, jsx-no-new-object-as-prop -- test rendering helpers and fixture assertions */
/* eslint-disable max-expects, jsx-no-new-object-as-prop, max-lines -- test rendering helpers and fixture assertions */
// @vitest-environment jsdom

import { describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -186,3 +186,123 @@ describe('workflow tool exchange rendering', () => {
expect(container.textContent).toContain('completed');
});
});

describe('agent tool exchange rendering', () => {
it('renders a completed agent tool with its title, arguments, and result', () => {
const toolCall = {
arguments: { filePath: 'src/auth.ts' },
id: 'tc-agent-1',
name: 'read',
source: 'agent' as const,
title: 'src/auth.ts',
type: 'tool-call' as const,
};
const result = {
id: 'tr-agent-1',
ok: true,
toolCallId: 'tc-agent-1',
type: 'tool-result' as const,
value: 'export const guard = () => true;',
};
const item: GroupedConversationItem = {
result,
toolCall,
type: 'tool-exchange',
};

const { container } = render(<AgentConversationItemView item={item} />);

expect(container.textContent).toContain('read');
expect(container.textContent).toContain('completed');
expect(container.textContent).toContain('src/auth.ts');
expect(container.textContent).toContain('Arguments');
expect(container.textContent).toContain('filePath');
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('export const guard = () => true;');
});

it('renders a failed agent tool with the error label and reason', () => {
const toolCall = {
arguments: { filePath: 'src/auth.ts' },
id: 'tc-agent-2',
name: 'read',
source: 'agent' as const,
title: 'src/auth.ts',
type: 'tool-call' as const,
};
const result = {
error: 'File not found.',
id: 'tr-agent-2',
ok: false,
toolCallId: 'tc-agent-2',
type: 'tool-result' as const,
};
const item: GroupedConversationItem = {
result,
toolCall,
type: 'tool-exchange',
};

const { container } = render(<AgentConversationItemView item={item} />);

expect(container.textContent).toContain('read');
expect(container.textContent).toContain('failed');
expect(container.textContent).toContain('Error');
expect(container.textContent).toContain('File not found.');
});

it('renders a running agent tool without a result block', () => {
const toolCall = {
arguments: { filePath: 'src/auth.ts' },
id: 'tc-agent-3',
name: 'read',
source: 'agent' as const,
title: 'src/auth.ts',
type: 'tool-call' as const,
};
const item: GroupedConversationItem = {
toolCall,
type: 'tool-exchange',
};

const { container } = render(<AgentConversationItemView item={item} />);

expect(container.textContent).toContain('read');
expect(container.textContent).toContain('running');
expect(container.textContent).toContain('Arguments');
expect(container.textContent).not.toContain('Result');
});

it('renders an agent tool image and no result pre', () => {
const toolCall = {
arguments: { fullPage: false },
id: 'tc-agent-4',
name: 'browser_screenshot',
source: 'agent' as const,
title: 'viewport',
type: 'tool-call' as const,
};
const result = {
id: 'tr-agent-4',
imageDataUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
ok: true,
toolCallId: 'tc-agent-4',
type: 'tool-result' as const,
value: 'captured',
};
const item: GroupedConversationItem = {
result,
toolCall,
type: 'tool-exchange',
};

const { container } = render(<AgentConversationItemView item={item} />);

const image = container.querySelector('img');
expect(image).not.toBeNull();
expect(image?.getAttribute('src')).toContain('data:image/png;base64,');
expect(image?.getAttribute('alt')).toBe('Image produced by browser_screenshot');
expect(container.textContent).not.toContain('captured');
});
});
136 changes: 105 additions & 31 deletions apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Shared tool panel and per-state mappers for both conversation renderers */
import type { JSX, ReactNode } from 'react';
import { isValidElement } from 'react';
import type { Components } from 'react-markdown';
Expand Down Expand Up @@ -155,15 +156,29 @@ const ThinkingEvent = ({
</details>
);

const ToolExchangeEvent = ({
item,
const ToolExchangePanel = ({
argumentsText,
codeText,
imageDataUrl,
imageAlt,
resultText,
status,
subtitle,
title,
}: {
item: Extract<GroupedConversationItem, { readonly type: 'tool-exchange' }>;
/** Rendered under "Arguments". Omit to hide the block. */
argumentsText?: string | undefined;
/** Rendered under "Code". Omit to hide the block. */
codeText?: string | undefined;
imageDataUrl?: string | undefined;
imageAlt: string;
/** Rendered under "Result" or "Error". Omit while the tool still runs. */
resultText?: string | undefined;
status: 'completed' | 'failed' | 'running';
subtitle: string;
title: string;
}): JSX.Element => {
const isSuccessful = item.result.ok;
const screenshotDataUrl = isSuccessful
? getViewportScreenshotDataUrl(item.toolCall.name, item.result.value)
: undefined;
const isSuccessful = status !== 'failed';

const panelClassName = isSuccessful
? 'group min-w-0 rounded-lg border border-border bg-surface-inset px-3 py-2'
Expand Down Expand Up @@ -191,44 +206,103 @@ const ToolExchangeEvent = ({
<details className={panelClassName}>
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 outline-none transition focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background">
<span className={titleClassName}>
{item.toolCall.name} {isSuccessful ? 'completed' : 'failed'}
</span>
<span className={tabClassName}>
{'serverName' in item.toolCall ? item.toolCall.serverName : `tab ${item.toolCall.tabId}`}
{title} {status}
</span>
<span className={tabClassName}>{subtitle}</span>
</summary>
<div className="mt-2 grid min-w-0 gap-2">
{item.toolCall.name === 'eval' ? (
{codeText === undefined ? null : (
<div className="min-w-0">
<p className={codeLabelClassName}>Code</p>
<pre className={codeBlockClassName}>{item.toolCall.code}</pre>
<pre className={codeBlockClassName}>{codeText}</pre>
</div>
) : null}
{'arguments' in item.toolCall ? (
)}
{argumentsText === undefined ? null : (
<div className="min-w-0">
<p className={codeLabelClassName}>Arguments</p>
<pre className={codeBlockClassName}>{formatToolValue(item.toolCall.arguments)}</pre>
<pre className={codeBlockClassName}>{argumentsText}</pre>
</div>
) : null}
<div className="min-w-0">
<p className={resultLabelClassName}>{isSuccessful ? 'Result' : 'Error'}</p>
{screenshotDataUrl === undefined ? (
<pre className={resultBlockClassName}>
{isSuccessful ? formatToolValue(item.result.value) : item.result.error}
</pre>
) : (
<img
alt="Viewport screenshot captured by get_viewport_screenshot"
className="mt-1 max-h-40 max-w-full rounded-md border border-border object-contain"
src={screenshotDataUrl}
/>
)}
</div>
)}
{resultText === undefined && imageDataUrl === undefined ? null : (
<div className="min-w-0">
<p className={resultLabelClassName}>{status === 'failed' ? 'Error' : 'Result'}</p>
{imageDataUrl === undefined ? (
<pre className={resultBlockClassName}>{resultText}</pre>
) : (
<img
alt={imageAlt}
className="mt-1 max-h-40 max-w-full rounded-md border border-border object-contain"
src={imageDataUrl}
/>
)}
</div>
)}
</div>
</details>
);
};

type ToolResultEvent = Extract<AgentConversationEvent, { readonly type: 'tool-result' }>;

const getToolExchangeStatus = (
result: ToolResultEvent | undefined
): 'completed' | 'failed' | 'running' => {
if (result === undefined) {
return 'running';
}

return result.ok ? 'completed' : 'failed';
};

const getToolExchangeResultText = (
result: ToolResultEvent | undefined,
hasResultImage: boolean
): string | undefined => {
if (result === undefined || hasResultImage) {
return undefined;
}

return result.ok ? formatToolValue(result.value) : (result.error ?? '');
};

const ToolExchangeEvent = ({
item,
}: {
item: Extract<GroupedConversationItem, { readonly type: 'tool-exchange' }>;
}): JSX.Element => {
const { result, toolCall } = item;

if ('source' in toolCall) {
return (
<ToolExchangePanel
argumentsText={formatToolValue(toolCall.arguments)}
imageAlt={`Image produced by ${toolCall.name}`}
imageDataUrl={result?.imageDataUrl}
resultText={getToolExchangeResultText(result, result?.imageDataUrl !== undefined)}
status={getToolExchangeStatus(result)}
subtitle={toolCall.title ?? ''}
title={toolCall.name}
/>
);
}

const screenshotDataUrl =
result?.ok === true ? getViewportScreenshotDataUrl(toolCall.name, result.value) : undefined;

return (
<ToolExchangePanel
argumentsText={'arguments' in toolCall ? formatToolValue(toolCall.arguments) : undefined}
codeText={'code' in toolCall ? toolCall.code : undefined}
imageAlt="Viewport screenshot captured by get_viewport_screenshot"
imageDataUrl={screenshotDataUrl}
resultText={getToolExchangeResultText(result, screenshotDataUrl !== undefined)}
status={getToolExchangeStatus(result)}
subtitle={'serverName' in toolCall ? toolCall.serverName : `tab ${toolCall.tabId}`}
title={toolCall.name}
/>
);
};

const StandaloneToolEvent = ({
event,
}: {
Expand Down
25 changes: 15 additions & 10 deletions apps/extension/entrypoints/sidepanel/agents-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,24 @@ export const AgentsComposer = ({
placeholder="Send a message…"
value={draft}
/>
<div className="mt-2 grid gap-2">
<div className="mt-2 flex gap-2">
<button
className={
isStreaming
? 'type-label h-9 w-full rounded-md border border-border bg-surface-overlay px-3 text-foreground-on-secondary transition hover:bg-surface-hover outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background'
: 'type-label h-9 w-full rounded-md border border-transparent bg-brand-primary px-3 text-brand-primary-foreground transition hover:bg-brand-primary-hover outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:bg-surface-selected disabled:text-foreground-subtle'
}
disabled={isStreaming ? !canInterrupt : isSendDisabled}
onClick={isStreaming && canInterrupt ? onStop : undefined}
type={isStreaming && canInterrupt ? 'button' : 'submit'}
className={`type-label h-9 ${isStreaming ? 'flex-1' : 'w-full'} rounded-md border border-transparent bg-brand-primary px-3 text-brand-primary-foreground transition hover:bg-brand-primary-hover outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:bg-surface-selected disabled:text-foreground-subtle`}
disabled={isSendDisabled}
type="submit"
>
{isStreaming ? 'Stop' : 'Send message'}
Send message
</button>
{isStreaming ? (
<button
className="type-label h-9 w-20 shrink-0 rounded-md border border-border bg-surface-overlay px-3 text-foreground-on-secondary transition hover:bg-surface-hover outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canInterrupt}
onClick={onStop}
type="button"
>
Stop
</button>
) : null}
</div>
</form>
);
Expand Down
Loading