Skip to content

Commit efd11ad

Browse files
merge: Desktop P0 IM composer transcript
Merge Desktop P0 integration after local focused gate and green frontend-desktop CI. Non-Desktop CI red items are existing baseline debt tracked in roadmap.
2 parents 0eba8cf + 962c7a8 commit efd11ad

17 files changed

Lines changed: 1307 additions & 119 deletions
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom/vitest';
4+
import IMBlockRenderer from '@/components/IM/IMBlockRenderer';
5+
import type { MessageBlock } from '@/components/ChatView.types';
6+
7+
Element.prototype.scrollIntoView = vi.fn();
8+
9+
vi.mock('react-i18next', () => ({
10+
useTranslation: () => ({
11+
t: (key: string, vars?: Record<string, unknown>) => {
12+
if (!vars) return key;
13+
const varStr = Object.entries(vars)
14+
.map(([k, v]) => `${k}=${v}`)
15+
.join(', ');
16+
return `${key}(${varStr})`;
17+
},
18+
i18n: { language: 'en' },
19+
}),
20+
}));
21+
22+
function renderBlocks(blocks: MessageBlock[]) {
23+
return render(<IMBlockRenderer content="" blocks={blocks} />);
24+
}
25+
26+
describe('IMBlockRenderer — new block types', () => {
27+
it('renders child_agent block with title and agent name', () => {
28+
const block: MessageBlock = {
29+
kind: 'child_agent',
30+
childId: 'child-1',
31+
title: 'Research Agent',
32+
status: 'running',
33+
agentName: 'ResearchBot',
34+
};
35+
renderBlocks([block]);
36+
expect(screen.getByText('Research Agent')).toBeInTheDocument();
37+
expect(screen.getByText('ResearchBot')).toBeInTheDocument();
38+
});
39+
40+
it('renders child_agent block with result when expanded', () => {
41+
const block: MessageBlock = {
42+
kind: 'child_agent',
43+
childId: 'child-2',
44+
title: 'Code Agent',
45+
status: 'completed',
46+
result: 'Refactored 3 files',
47+
};
48+
renderBlocks([block]);
49+
expect(screen.getByText('Code Agent')).toBeInTheDocument();
50+
fireEvent.click(screen.getByRole('button'));
51+
expect(screen.getByText('Refactored 3 files')).toBeInTheDocument();
52+
});
53+
54+
it('renders child_agent block with error', () => {
55+
const block: MessageBlock = {
56+
kind: 'child_agent',
57+
childId: 'child-3',
58+
title: 'Fail Agent',
59+
status: 'failed',
60+
error: 'timeout exceeded',
61+
};
62+
renderBlocks([block]);
63+
expect(screen.getByText('Fail Agent')).toBeInTheDocument();
64+
fireEvent.click(screen.getByRole('button'));
65+
expect(screen.getByText('timeout exceeded')).toBeInTheDocument();
66+
});
67+
68+
it('renders child_agent fallback to childId when no title', () => {
69+
const block: MessageBlock = {
70+
kind: 'child_agent',
71+
childId: 'child-orphan',
72+
title: '',
73+
status: 'pending',
74+
};
75+
renderBlocks([block]);
76+
expect(screen.getByText('child-orphan')).toBeInTheDocument();
77+
});
78+
79+
it('renders route_decision block with action', () => {
80+
const block: MessageBlock = {
81+
kind: 'route_decision',
82+
action: 'delegate',
83+
nextWorker: 'coder',
84+
summary: 'Routing to coder agent',
85+
};
86+
renderBlocks([block]);
87+
expect(screen.getByText('delegate')).toBeInTheDocument();
88+
expect(screen.getByText('coder')).toBeInTheDocument();
89+
fireEvent.click(screen.getByRole('button'));
90+
expect(screen.getByText('Routing to coder agent')).toBeInTheDocument();
91+
});
92+
93+
it('renders route_decision block with blocked reason', () => {
94+
const block: MessageBlock = {
95+
kind: 'route_decision',
96+
action: 'blocked',
97+
blockedReason: 'awaiting approval',
98+
};
99+
renderBlocks([block]);
100+
expect(screen.getByText('blocked')).toBeInTheDocument();
101+
expect(screen.getByText('awaiting approval')).toBeInTheDocument();
102+
});
103+
104+
it('renders route_decision block with reasoning when expanded', () => {
105+
const block: MessageBlock = {
106+
kind: 'route_decision',
107+
action: 'retry',
108+
reasoning: 'Network timeout caused failure',
109+
};
110+
renderBlocks([block]);
111+
expect(screen.getByText('retry')).toBeInTheDocument();
112+
fireEvent.click(screen.getByRole('button'));
113+
expect(screen.getByText('Network timeout caused failure')).toBeInTheDocument();
114+
});
115+
116+
it('renders artifact block with type and title', () => {
117+
const block: MessageBlock = {
118+
kind: 'artifact',
119+
artifactId: 'art-1',
120+
artifactType: 'document',
121+
title: 'Final Report',
122+
};
123+
renderBlocks([block]);
124+
expect(screen.getByText('document')).toBeInTheDocument();
125+
expect(screen.getByText('Final Report')).toBeInTheDocument();
126+
});
127+
128+
it('renders artifact block with URL as link', () => {
129+
const block: MessageBlock = {
130+
kind: 'artifact',
131+
artifactId: 'art-2',
132+
artifactType: 'image',
133+
title: 'Screenshot',
134+
artifactUrl: 'https://example.com/img.png',
135+
};
136+
renderBlocks([block]);
137+
const link = screen.getByText('https://example.com/img.png');
138+
expect(link).toBeInTheDocument();
139+
expect(link.closest('a')).toHaveAttribute('href', 'https://example.com/img.png');
140+
});
141+
142+
it('renders artifact block with size', () => {
143+
const block: MessageBlock = {
144+
kind: 'artifact',
145+
artifactId: 'art-3',
146+
artifactType: 'file',
147+
title: 'Big Data',
148+
size: 2048,
149+
};
150+
renderBlocks([block]);
151+
expect(screen.getByText('2.0 KB')).toBeInTheDocument();
152+
});
153+
154+
it('renders sub-kilobyte artifact sizes in bytes', () => {
155+
const block: MessageBlock = {
156+
kind: 'artifact',
157+
artifactId: 'art-small',
158+
artifactType: 'file',
159+
title: 'Small Log',
160+
size: 500,
161+
};
162+
renderBlocks([block]);
163+
expect(screen.getByText('500 B')).toBeInTheDocument();
164+
});
165+
166+
it('renders deploy_card block with status and message', () => {
167+
const block: MessageBlock = {
168+
kind: 'deploy_card',
169+
deployId: 'deploy-1',
170+
status: 'success',
171+
statusMessage: 'Deployed to production',
172+
};
173+
renderBlocks([block]);
174+
expect(screen.getByText('Deploy')).toBeInTheDocument();
175+
expect(screen.getByText('success')).toBeInTheDocument();
176+
expect(screen.getByText('Deployed to production')).toBeInTheDocument();
177+
});
178+
179+
it('renders deploy_card block with URL as link', () => {
180+
const block: MessageBlock = {
181+
kind: 'deploy_card',
182+
deployId: 'deploy-2',
183+
status: 'running',
184+
url: 'https://app.example.com',
185+
};
186+
renderBlocks([block]);
187+
const link = screen.getByText('https://app.example.com');
188+
expect(link).toBeInTheDocument();
189+
expect(link.closest('a')).toHaveAttribute('href', 'https://app.example.com');
190+
});
191+
192+
it('renders deploy_card with failed status and message', () => {
193+
const block: MessageBlock = {
194+
kind: 'deploy_card',
195+
status: 'failed',
196+
statusMessage: 'Build error',
197+
};
198+
renderBlocks([block]);
199+
expect(screen.getByText('failed')).toBeInTheDocument();
200+
expect(screen.getByText('Build error')).toBeInTheDocument();
201+
});
202+
});
203+
204+
describe('IMBlockRenderer — existing blocks still render', () => {
205+
it('renders text block via MarkdownRenderer', () => {
206+
renderBlocks([{ kind: 'text', content: 'Hello **world**' }]);
207+
expect(screen.getByText('world')).toBeInTheDocument();
208+
});
209+
210+
it('renders tool_use block', () => {
211+
renderBlocks([{
212+
kind: 'tool_use',
213+
callId: 'c1',
214+
toolName: 'Read',
215+
input: { file_path: 'src/app.ts' },
216+
status: 'completed',
217+
}]);
218+
expect(screen.getByText('Read')).toBeInTheDocument();
219+
expect(screen.getByText('src/app.ts')).toBeInTheDocument();
220+
});
221+
222+
it('renders thinking block toggle', () => {
223+
renderBlocks([{ kind: 'thinking', content: 'Deep thoughts' }]);
224+
expect(screen.getByText('chat.thinkingSettledLabel')).toBeInTheDocument();
225+
});
226+
227+
it('renders approval block with agent and tool names', () => {
228+
renderBlocks([{
229+
kind: 'approval',
230+
approvalId: 'a1',
231+
status: 'pending',
232+
agentName: 'Bot',
233+
toolName: 'Bash',
234+
}]);
235+
expect(screen.getByText('Bot')).toBeInTheDocument();
236+
expect(screen.getByText('Bash')).toBeInTheDocument();
237+
});
238+
239+
it('renders file_change block', () => {
240+
renderBlocks([{
241+
kind: 'file_change',
242+
path: '/src/main.ts',
243+
action: 'modified',
244+
}]);
245+
expect(screen.getByText('modified')).toBeInTheDocument();
246+
expect(screen.getByText('main.ts')).toBeInTheDocument();
247+
});
248+
249+
it('renders error block', () => {
250+
renderBlocks([{
251+
kind: 'error',
252+
message: 'Something went wrong',
253+
category: 'network',
254+
}]);
255+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
256+
});
257+
258+
it('falls back to markdown for plain content without blocks', () => {
259+
render(<IMBlockRenderer content="Plain **markdown** text" />);
260+
expect(screen.getByText('markdown')).toBeInTheDocument();
261+
});
262+
});

app/desktop/src/__tests__/IMMessageView.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,24 @@ describe('IMMessageView', () => {
103103
expect(screen.getByText('From user')).toBeInTheDocument();
104104
expect(screen.getByText('From agent')).toBeInTheDocument();
105105
});
106+
107+
it('shows pending indicator for optimistic send state', () => {
108+
const msg = makeMsg({ content: 'Pending body', sendState: 'pending' });
109+
render(<IMMessageView messages={[msg]} />);
110+
expect(screen.getByText('Sending...')).toBeInTheDocument();
111+
expect(screen.getByText('Pending body')).toBeInTheDocument();
112+
});
113+
114+
it('shows failed indicator with error message', () => {
115+
const msg = makeMsg({ content: 'Failed msg', sendState: 'failed', sendError: 'Timeout' });
116+
render(<IMMessageView messages={[msg]} />);
117+
expect(screen.getByText('Timeout')).toBeInTheDocument();
118+
expect(screen.getByText('Failed msg')).toBeInTheDocument();
119+
});
120+
121+
it('shows default failed label when sendError is absent', () => {
122+
const msg = makeMsg({ content: 'Oops', sendState: 'failed' });
123+
render(<IMMessageView messages={[msg]} />);
124+
expect(screen.getByText('Send failed')).toBeInTheDocument();
125+
});
106126
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
formatBytes,
4+
formatAttachmentContext,
5+
shouldPreviewBrowserFile,
6+
browserFilesToAttachments,
7+
pathBasename,
8+
type PromptAttachment,
9+
} from '@/utils/attachment';
10+
11+
describe('attachment utils', () => {
12+
describe('formatBytes', () => {
13+
it('returns undefined for null/undefined', () => {
14+
expect(formatBytes(undefined)).toBeUndefined();
15+
expect(formatBytes(null as any)).toBeUndefined();
16+
});
17+
18+
it('formats bytes', () => {
19+
expect(formatBytes(500)).toBe('500 B');
20+
});
21+
22+
it('formats kilobytes', () => {
23+
expect(formatBytes(1536)).toBe('1.5 KB');
24+
});
25+
26+
it('formats megabytes', () => {
27+
expect(formatBytes(2 * 1024 * 1024)).toBe('2.0 MB');
28+
});
29+
});
30+
31+
describe('formatAttachmentContext', () => {
32+
it('returns empty string for empty array', () => {
33+
expect(formatAttachmentContext([])).toBe('');
34+
});
35+
36+
it('formats a single attachment', () => {
37+
const attachments: PromptAttachment[] = [
38+
{ id: '1', name: 'test.ts', source: 'browser', size: 1024 },
39+
];
40+
const result = formatAttachmentContext(attachments);
41+
expect(result).toContain('test.ts');
42+
expect(result).toContain('1.0 KB');
43+
expect(result).toContain('Browser file picker');
44+
});
45+
46+
it('includes path for desktop attachments', () => {
47+
const attachments: PromptAttachment[] = [
48+
{ id: '1', name: 'file.ts', source: 'desktop', path: '/home/user/file.ts' },
49+
];
50+
const result = formatAttachmentContext(attachments);
51+
expect(result).toContain('/home/user/file.ts');
52+
});
53+
});
54+
55+
describe('shouldPreviewBrowserFile', () => {
56+
it('returns true for text/ types', () => {
57+
const file = new File([''], 'a.csv', { type: 'text/csv' });
58+
expect(shouldPreviewBrowserFile(file)).toBe(true);
59+
});
60+
61+
it('returns true for known extensions', () => {
62+
const file = new File([''], 'a.tsx', { type: '' });
63+
expect(shouldPreviewBrowserFile(file)).toBe(true);
64+
});
65+
66+
it('returns false for unknown binary types', () => {
67+
const file = new File([''], 'image.png', { type: 'image/png' });
68+
expect(shouldPreviewBrowserFile(file)).toBe(false);
69+
});
70+
});
71+
72+
describe('browserFilesToAttachments', () => {
73+
it('converts files to attachments', async () => {
74+
const files = [
75+
new File(['hello'], 'test.txt', { type: 'text/plain' }),
76+
];
77+
const result = await browserFilesToAttachments(files);
78+
expect(result).toHaveLength(1);
79+
expect(result[0].name).toBe('test.txt');
80+
expect(result[0].source).toBe('browser');
81+
expect(result[0].contentPreview).toBe('hello');
82+
});
83+
});
84+
85+
describe('pathBasename', () => {
86+
it('extracts filename from unix path', () => {
87+
expect(pathBasename('/home/user/file.ts')).toBe('file.ts');
88+
});
89+
90+
it('extracts filename from windows path', () => {
91+
expect(pathBasename('C:\\Users\\file.ts')).toBe('file.ts');
92+
});
93+
94+
it('returns input when no separator', () => {
95+
expect(pathBasename('file.ts')).toBe('file.ts');
96+
});
97+
});
98+
});

0 commit comments

Comments
 (0)