@classic-homes/agent-chat 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +57 -2
  2. package/dist/components/AgentChat.svelte +61 -22
  3. package/dist/components/AgentChat.svelte.d.ts +11 -1
  4. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  5. package/dist/components/ArtifactPanel.svelte +4 -2
  6. package/dist/components/AssistantMessage.svelte +14 -4
  7. package/dist/components/AssistantMessage.svelte.d.ts +2 -0
  8. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
  9. package/dist/components/ChatHeader.svelte +4 -1
  10. package/dist/components/ChatHeader.svelte.d.ts +2 -0
  11. package/dist/components/ChatHeader.svelte.d.ts.map +1 -1
  12. package/dist/components/ChatLauncher.svelte +157 -0
  13. package/dist/components/ChatLauncher.svelte.d.ts +23 -0
  14. package/dist/components/ChatLauncher.svelte.d.ts.map +1 -0
  15. package/dist/components/Composer.svelte +7 -3
  16. package/dist/components/Composer.svelte.d.ts.map +1 -1
  17. package/dist/components/FloatingWindow.svelte +239 -0
  18. package/dist/components/FloatingWindow.svelte.d.ts +26 -0
  19. package/dist/components/FloatingWindow.svelte.d.ts.map +1 -0
  20. package/dist/components/Markdown.svelte +4 -0
  21. package/dist/components/Markdown.svelte.d.ts.map +1 -1
  22. package/dist/components/MessageFeedback.svelte +11 -4
  23. package/dist/components/MessageFeedback.svelte.d.ts.map +1 -1
  24. package/dist/components/MessageFiles.svelte +133 -40
  25. package/dist/components/MessageFiles.svelte.d.ts +6 -0
  26. package/dist/components/MessageFiles.svelte.d.ts.map +1 -1
  27. package/dist/components/MessageItem.svelte +2 -0
  28. package/dist/components/MessageItem.svelte.d.ts +2 -0
  29. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  30. package/dist/components/MessageList.svelte +40 -7
  31. package/dist/components/MessageList.svelte.d.ts +3 -1
  32. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  33. package/dist/components/ShortcutsDialog.svelte +10 -3
  34. package/dist/components/ShortcutsDialog.svelte.d.ts +2 -0
  35. package/dist/components/ShortcutsDialog.svelte.d.ts.map +1 -1
  36. package/dist/components/UiFrame.svelte +12 -4
  37. package/dist/components/UiFrame.svelte.d.ts.map +1 -1
  38. package/dist/components/UserMessage.svelte +10 -1
  39. package/dist/components/UserMessage.svelte.d.ts.map +1 -1
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -0
  43. package/dist/markdown.d.ts.map +1 -1
  44. package/dist/markdown.js +94 -61
  45. package/dist/reducer.d.ts +11 -0
  46. package/dist/reducer.d.ts.map +1 -1
  47. package/dist/reducer.js +18 -2
  48. package/dist/session.svelte.d.ts +46 -0
  49. package/dist/session.svelte.d.ts.map +1 -1
  50. package/dist/session.svelte.js +208 -13
  51. package/dist/shortcuts.d.ts +7 -0
  52. package/dist/shortcuts.d.ts.map +1 -1
  53. package/dist/shortcuts.js +9 -0
  54. package/dist/surface-geometry.d.ts +56 -0
  55. package/dist/surface-geometry.d.ts.map +1 -0
  56. package/dist/surface-geometry.js +99 -0
  57. package/dist/surface.svelte.d.ts +69 -0
  58. package/dist/surface.svelte.d.ts.map +1 -0
  59. package/dist/surface.svelte.js +156 -0
  60. package/dist/ui-frame-protocol.d.ts +6 -0
  61. package/dist/ui-frame-protocol.d.ts.map +1 -1
  62. package/dist/ui-frame-protocol.js +11 -0
  63. package/package.json +8 -7
  64. package/dist/artifact-pages.test.d.ts +0 -2
  65. package/dist/artifact-pages.test.d.ts.map +0 -1
  66. package/dist/artifact-pages.test.js +0 -102
  67. package/dist/components/a11y.svelte.test.d.ts +0 -2
  68. package/dist/components/a11y.svelte.test.d.ts.map +0 -1
  69. package/dist/components/a11y.svelte.test.js +0 -183
  70. package/dist/components/agent-picker.svelte.test.d.ts +0 -2
  71. package/dist/components/agent-picker.svelte.test.d.ts.map +0 -1
  72. package/dist/components/agent-picker.svelte.test.js +0 -84
  73. package/dist/components/composer.svelte.test.d.ts +0 -2
  74. package/dist/components/composer.svelte.test.d.ts.map +0 -1
  75. package/dist/components/composer.svelte.test.js +0 -162
  76. package/dist/components/conversation-chrome.svelte.test.d.ts +0 -2
  77. package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
  78. package/dist/components/conversation-chrome.svelte.test.js +0 -147
  79. package/dist/components/feedback.svelte.test.d.ts +0 -2
  80. package/dist/components/feedback.svelte.test.d.ts.map +0 -1
  81. package/dist/components/feedback.svelte.test.js +0 -99
  82. package/dist/components/live-fixes.svelte.test.d.ts +0 -2
  83. package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
  84. package/dist/components/live-fixes.svelte.test.js +0 -127
  85. package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
  86. package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
  87. package/dist/components/markdown-prose.svelte.test.js +0 -26
  88. package/dist/components/messages.svelte.test.d.ts +0 -2
  89. package/dist/components/messages.svelte.test.d.ts.map +0 -1
  90. package/dist/components/messages.svelte.test.js +0 -202
  91. package/dist/components/regenerate.svelte.test.d.ts +0 -2
  92. package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
  93. package/dist/components/regenerate.svelte.test.js +0 -108
  94. package/dist/components/welcome.svelte.test.d.ts +0 -2
  95. package/dist/components/welcome.svelte.test.d.ts.map +0 -1
  96. package/dist/components/welcome.svelte.test.js +0 -119
  97. package/dist/composer-keys.test.d.ts +0 -2
  98. package/dist/composer-keys.test.d.ts.map +0 -1
  99. package/dist/composer-keys.test.js +0 -16
  100. package/dist/markdown.test.d.ts +0 -2
  101. package/dist/markdown.test.d.ts.map +0 -1
  102. package/dist/markdown.test.js +0 -35
  103. package/dist/reducer.test.d.ts +0 -2
  104. package/dist/reducer.test.d.ts.map +0 -1
  105. package/dist/reducer.test.js +0 -153
  106. package/dist/ui-frame-protocol.test.d.ts +0 -2
  107. package/dist/ui-frame-protocol.test.d.ts.map +0 -1
  108. package/dist/ui-frame-protocol.test.js +0 -48
  109. package/dist/ui-frame-relay.test.d.ts +0 -2
  110. package/dist/ui-frame-relay.test.d.ts.map +0 -1
  111. package/dist/ui-frame-relay.test.js +0 -36
@@ -1,26 +0,0 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- import { cleanup, render } from '@testing-library/svelte';
3
- import { parseMarkdown } from '../markdown.js';
4
- import { errorTitle } from '../reducer.js';
5
- import Markdown from './Markdown.svelte';
6
- afterEach(cleanup);
7
- describe('Markdown', () => {
8
- // The Classic App's cascade-layers polyfill outranks plain component CSS, so the prose rules
9
- // must be utilities on the container (classic-agent visual check, 2026-11-03).
10
- it('styles prose with utility classes, not a stylesheet', () => {
11
- const { container } = render(Markdown, {
12
- props: { html: parseMarkdown('- one\n- two'), class: 'text-sm' },
13
- });
14
- const root = container.firstElementChild;
15
- expect(root.className).toContain('[&_ul]:list-disc');
16
- expect(root.className).toContain('[&_td]:border');
17
- expect(root.className).toContain('text-sm');
18
- expect(root.querySelectorAll('li')).toHaveLength(2);
19
- expect(document.querySelectorAll('style')).toHaveLength(0);
20
- });
21
- });
22
- describe('errorTitle', () => {
23
- it('falls back for a code this client does not know', () => {
24
- expect(errorTitle('upstream')).toBe('Something went wrong');
25
- });
26
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=messages.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"messages.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/messages.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,202 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { parseMarkdown } from '../markdown.js';
5
- import { daySeparators, describeProposal, formatBytes, formatDay } from '../message-format.js';
6
- import { newAssistantMessage, newUserMessage } from '../reducer.js';
7
- import AssistantMessageView from './AssistantMessage.svelte';
8
- import Markdown from './Markdown.svelte';
9
- import MessageList from './MessageList.svelte';
10
- import UserMessageView from './UserMessage.svelte';
11
- afterEach(() => cleanup());
12
- const base = { sandboxOrigin: 'https://frame.test', fileUrl: (id) => `/files/${id}` };
13
- const assistant = (patch = {}) => ({
14
- ...newAssistantMessage('r1', Date.UTC(2026, 10, 3, 15, 0)),
15
- status: 'done',
16
- ...patch,
17
- });
18
- const DAY = 86_400_000;
19
- describe('message formatting', () => {
20
- it('formats sizes and relative days', () => {
21
- expect(formatBytes(900)).toBe('900 B');
22
- expect(formatBytes(2048)).toBe('2 KB');
23
- expect(formatBytes(3 * 1048576)).toBe('3.0 MB');
24
- const now = new Date(2026, 10, 3, 12).getTime();
25
- expect(formatDay(now - 60_000, now)).toBe('Today');
26
- expect(formatDay(now - DAY, now)).toBe('Yesterday');
27
- expect(formatDay(now - 5 * DAY, now)).not.toMatch(/Today|Yesterday/);
28
- });
29
- it('marks the first message of each day', () => {
30
- const t = new Date(2026, 10, 1, 10).getTime();
31
- expect([...daySeparators([{ createdAt: t }, { createdAt: t + 60_000 }])]).toEqual([0]);
32
- expect([...daySeparators([{ createdAt: t }, { createdAt: t + DAY }])]).toEqual([0, 1]);
33
- });
34
- it('describes proposals without applying them', () => {
35
- const op = {
36
- kind: 'excel.writeRange',
37
- operationId: '00000000-0000-4000-8000-000000000001',
38
- documentId: 'doc-1',
39
- summary: 'Fill totals',
40
- target: { sheet: 'Budget', address: 'B2:B4' },
41
- write: { type: 'values', values: [[1], [2], [3]] },
42
- precondition: { formulas: [[''], [''], ['']] },
43
- };
44
- expect(describeProposal(op)).toEqual({
45
- action: 'Write values',
46
- host: 'Excel',
47
- target: 'Budget!B2:B4',
48
- });
49
- });
50
- });
51
- describe('markdown output', () => {
52
- it('wraps tables in a scrolling container and never trusts a literal div', () => {
53
- const html = parseMarkdown('| A | B |\n|---|---|\n| 1 | 2 |\n\n<div class="agent-x">hi</div>');
54
- expect(html).toMatch(/<div class="agent-table-wrap"><table class="agent-table">/);
55
- expect(html).toContain('&lt;div');
56
- expect(html.match(/<div/g)).toHaveLength(1);
57
- });
58
- it('marks external links', () => {
59
- expect(parseMarkdown('[docs](https://example.com/)')).toContain('class="agent-external-link"');
60
- });
61
- });
62
- describe('Markdown code blocks', () => {
63
- it('adds a language header whose button copies the code', async () => {
64
- const writeText = vi.fn().mockResolvedValue(undefined);
65
- Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
66
- render(Markdown, { props: { html: parseMarkdown('```ts\nconst a = 1;\n```') } });
67
- const button = await screen.findByRole('button', { name: 'Copy ts code' });
68
- expect(button.closest('.agent-codeblock')?.textContent).toContain('ts');
69
- await fireEvent.click(button);
70
- expect(writeText).toHaveBeenCalledWith('const a = 1;');
71
- await waitFor(() => expect(button.textContent).toContain('Copied'));
72
- });
73
- });
74
- describe('AssistantMessage', () => {
75
- it('shows a retryable error with Try again', async () => {
76
- const onretry = vi.fn();
77
- render(AssistantMessageView, {
78
- props: {
79
- ...base,
80
- last: true,
81
- onretry,
82
- message: assistant({
83
- status: 'error',
84
- error: { code: 'upstream_error', message: 'Model failed.', retryable: true },
85
- }),
86
- },
87
- });
88
- expect(screen.getByText(/The agent ran into a problem/)).toBeTruthy();
89
- await userEvent.click(screen.getByRole('button', { name: /Try again/ }));
90
- expect(onretry).toHaveBeenCalledOnce();
91
- });
92
- it('offers Continue on a cut-short or stopped last answer only', async () => {
93
- const oncontinue = vi.fn();
94
- const { rerender } = render(AssistantMessageView, {
95
- props: { ...base, last: true, oncontinue, message: assistant({ status: 'incomplete' }) },
96
- });
97
- expect(screen.getByText('The answer was cut short.')).toBeTruthy();
98
- await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
99
- expect(oncontinue).toHaveBeenCalledOnce();
100
- await rerender({
101
- ...base,
102
- last: true,
103
- oncontinue,
104
- message: assistant({ status: 'cancelled' }),
105
- });
106
- expect(screen.getByText('Stopped')).toBeTruthy();
107
- expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy();
108
- await rerender({
109
- ...base,
110
- last: false,
111
- oncontinue,
112
- message: assistant({ status: 'cancelled' }),
113
- });
114
- expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
115
- });
116
- it('renders artifacts, proposals and a thinking state', async () => {
117
- const onopenartifact = vi.fn();
118
- const { rerender } = render(AssistantMessageView, {
119
- props: { ...base, streaming: true, message: assistant({ status: 'streaming' }) },
120
- });
121
- expect(screen.getByRole('status').textContent).toContain('Thinking');
122
- await rerender({
123
- ...base,
124
- onopenartifact,
125
- message: assistant({
126
- artifacts: [
127
- { artifactId: 'a1', mediaType: 'text/html', title: 'Plans', text: '', open: false },
128
- { artifactId: 'a2', mediaType: 'text/markdown', title: 'Memo', text: '', open: true },
129
- ],
130
- proposals: [
131
- {
132
- kind: 'word.replaceSelection',
133
- operationId: '00000000-0000-4000-8000-000000000002',
134
- documentId: 'doc-1',
135
- summary: 'Tighten the intro',
136
- target: { targetId: 't1', expectedText: 'Old' },
137
- text: 'New',
138
- },
139
- ],
140
- }),
141
- });
142
- expect(screen.queryByText('Thinking')).toBeNull();
143
- await userEvent.click(screen.getByRole('button', { name: 'Open Plans' }));
144
- expect(onopenartifact).toHaveBeenCalledWith('a1');
145
- expect(screen.getByRole('button', { name: 'Memo (writing)' })).toBeTruthy();
146
- expect(screen.getByText('Tighten the intro')).toBeTruthy();
147
- expect(screen.getByText('Apply from the Word add-in.')).toBeTruthy();
148
- });
149
- it('has a copy action once the answer is done', () => {
150
- render(AssistantMessageView, {
151
- props: { ...base, message: assistant({ text: 'Hello there' }) },
152
- });
153
- expect(screen.getByRole('button', { name: 'Copy message' })).toBeTruthy();
154
- });
155
- });
156
- describe('UserMessage', () => {
157
- it('shows the text in a bubble with its attachments', () => {
158
- const message = newUserMessage('r1', 'See attached', {
159
- attachments: [
160
- {
161
- fileId: 'f1',
162
- name: 'plan.pdf',
163
- mimeType: 'application/pdf',
164
- bytes: 2048,
165
- status: 'uploaded',
166
- },
167
- ],
168
- });
169
- render(UserMessageView, { props: { message } });
170
- expect(screen.getByText('See attached')).toBeTruthy();
171
- expect(screen.getByText('plan.pdf')).toBeTruthy();
172
- expect(screen.getByText('2 KB')).toBeTruthy();
173
- });
174
- });
175
- describe('MessageList', () => {
176
- it('labels days only when the transcript spans more than one', () => {
177
- const t = Date.now();
178
- const one = [newUserMessage('r1', 'hi', { now: t }), assistant({ createdAt: t })];
179
- const { rerender } = render(MessageList, {
180
- props: { ...base, streaming: false, messages: one },
181
- });
182
- expect(screen.queryByRole('separator')).toBeNull();
183
- const two = [newUserMessage('r0', 'old', { now: t - 3 * DAY }), ...one];
184
- void rerender({ ...base, streaming: false, messages: two });
185
- return waitFor(() => expect(screen.getAllByRole('separator').map((s) => s.textContent?.trim())).toContain('Today'));
186
- });
187
- it('offers a jump to the latest once the reader scrolls up', async () => {
188
- render(MessageList, {
189
- props: { ...base, streaming: false, messages: [newUserMessage('r1', 'hi'), assistant()] },
190
- });
191
- const log = screen.getByRole('log');
192
- Object.defineProperty(log, 'scrollHeight', { value: 2000, configurable: true });
193
- Object.defineProperty(log, 'clientHeight', { value: 500, configurable: true });
194
- log.scrollTop = 100;
195
- await fireEvent.scroll(log);
196
- const jump = await screen.findByRole('button', { name: 'Jump to the latest message' });
197
- log.scrollTo = vi.fn();
198
- await userEvent.click(jump);
199
- expect(log.scrollTo).toHaveBeenCalled();
200
- expect(screen.queryByRole('button', { name: /latest/ })).toBeNull();
201
- });
202
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=regenerate.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"regenerate.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/regenerate.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,108 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { latestTurn, newAssistantMessage, newUserMessage } from '../reducer.js';
5
- import { ChatSession } from '../session.svelte.js';
6
- import AgentChat from './AgentChat.svelte';
7
- afterEach(() => cleanup());
8
- /** Answers each request with "Answer N" (N counts requests). */
9
- function fakeClient() {
10
- let n = 0;
11
- return {
12
- fileUrl: (id) => `#${id}`,
13
- uploadFile: vi.fn(),
14
- cancel: vi.fn(),
15
- getConversation: vi.fn(),
16
- resumeTurn: vi.fn(),
17
- streamChat: vi.fn(async (req, opts) => {
18
- n += 1;
19
- [
20
- { type: 'start', conversationId: 'c-1', mode: 'live' },
21
- { type: 'delta', text: `Answer ${n}` },
22
- { type: 'done', finishReason: 'stop' },
23
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
24
- return { kind: 'done' };
25
- }),
26
- };
27
- }
28
- describe('latestTurn', () => {
29
- it('finds the finished latest turn only', () => {
30
- const done = { ...newAssistantMessage('r2'), status: 'done' };
31
- const messages = [
32
- newUserMessage('r1', 'one'),
33
- { ...newAssistantMessage('r1'), status: 'done' },
34
- newUserMessage('r2', 'two'),
35
- done,
36
- ];
37
- expect(latestTurn(messages, 'r2')?.user.text).toBe('two');
38
- expect(latestTurn(messages, 'r2')?.rest).toHaveLength(2);
39
- expect(latestTurn(messages, 'r1')).toBeUndefined();
40
- expect(latestTurn([...messages.slice(0, 3), newAssistantMessage('r2')], 'r2')).toBeUndefined();
41
- });
42
- });
43
- describe('ChatSession regenerate and edit', () => {
44
- it('replaces the latest turn instead of adding one', async () => {
45
- const client = fakeClient();
46
- const session = new ChatSession({ client: client });
47
- await session.send('first');
48
- const firstId = client.streamChat.mock.calls[0][0].requestId;
49
- await session.regenerate(firstId);
50
- const second = client.streamChat.mock.calls[1][0];
51
- expect(second).toMatchObject({ message: 'first', replaces: firstId, conversationId: 'c-1' });
52
- expect(session.messages.map((m) => m.text)).toEqual(['first', 'Answer 2']);
53
- await session.edit(second.requestId, 'first, but better');
54
- expect(client.streamChat.mock.calls[2][0]).toMatchObject({
55
- message: 'first, but better',
56
- replaces: second.requestId,
57
- });
58
- expect(session.messages.map((m) => m.text)).toEqual(['first, but better', 'Answer 3']);
59
- });
60
- it('sends a normal message without replaces', async () => {
61
- const client = fakeClient();
62
- const session = new ChatSession({ client: client });
63
- await session.send('hi');
64
- expect(client.streamChat.mock.calls[0][0]).not.toHaveProperty('replaces');
65
- });
66
- });
67
- describe('AgentChat regenerate and edit', () => {
68
- it('offers Regenerate on the latest answer and Edit on the latest question', async () => {
69
- const user = userEvent.setup();
70
- const client = fakeClient();
71
- render(AgentChat, { props: { client: client, sandboxOrigin: 'https://frame.test' } });
72
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'one{Enter}');
73
- await screen.findAllByText('Answer 1');
74
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'two{Enter}');
75
- await screen.findAllByText('Answer 2');
76
- // Only the latest turn.
77
- expect(screen.getAllByRole('button', { name: 'Regenerate response' })).toHaveLength(1);
78
- expect(screen.getAllByRole('button', { name: 'Edit message' })).toHaveLength(1);
79
- await user.click(screen.getByRole('button', { name: 'Regenerate response' }));
80
- await waitFor(() => expect(client.streamChat).toHaveBeenCalledTimes(3));
81
- expect(client.streamChat.mock.calls[2][0]).toMatchObject({ message: 'two' });
82
- expect((await screen.findAllByText('Answer 3')).length).toBeGreaterThan(0);
83
- expect(screen.queryByText('Answer 2')).toBeNull();
84
- await user.click(screen.getByRole('button', { name: 'Edit message' }));
85
- const field = screen.getByRole('textbox', { name: 'Edited message' });
86
- await user.clear(field);
87
- await user.type(field, 'two, edited{Enter}');
88
- await waitFor(() => expect(client.streamChat).toHaveBeenCalledTimes(4));
89
- expect(client.streamChat.mock.calls[3][0]).toMatchObject({
90
- message: 'two, edited',
91
- replaces: client.streamChat.mock.calls[2][0].requestId,
92
- });
93
- expect(await screen.findByText('two, edited')).toBeTruthy();
94
- expect(screen.queryByText(/^two$/)).toBeNull();
95
- });
96
- it('cancels an edit with Escape', async () => {
97
- const user = userEvent.setup();
98
- const client = fakeClient();
99
- render(AgentChat, { props: { client: client, sandboxOrigin: 'https://frame.test' } });
100
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'one{Enter}');
101
- await screen.findAllByText('Answer 1');
102
- await user.click(screen.getByRole('button', { name: 'Edit message' }));
103
- await user.type(screen.getByRole('textbox', { name: 'Edited message' }), ' more{Escape}');
104
- expect(screen.queryByRole('textbox', { name: 'Edited message' })).toBeNull();
105
- expect(screen.getByText('one')).toBeTruthy();
106
- expect(client.streamChat).toHaveBeenCalledOnce();
107
- });
108
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=welcome.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"welcome.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/welcome.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,119 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { cleanup, render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { greeting, suggestionText } from '../welcome.js';
5
- import AgentChat from './AgentChat.svelte';
6
- import Welcome from './Welcome.svelte';
7
- afterEach(() => cleanup());
8
- function fakeClient(getConversation = vi.fn()) {
9
- return {
10
- fileUrl: (id) => `#${id}`,
11
- uploadFile: vi.fn(),
12
- cancel: vi.fn(),
13
- getConversation,
14
- resumeTurn: vi.fn(),
15
- streamChat: vi.fn(async (req, opts) => {
16
- [
17
- { type: 'start', conversationId: 'c-1', mode: 'live' },
18
- { type: 'delta', text: 'Sure.' },
19
- { type: 'done', finishReason: 'stop' },
20
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
21
- return { kind: 'done' };
22
- }),
23
- };
24
- }
25
- const base = { sandboxOrigin: 'https://frame.test' };
26
- describe('welcome helpers', () => {
27
- it('greets by the time of day and the first name only', () => {
28
- expect(greeting('Pat Lee', new Date(2026, 10, 4, 9))).toBe('Good morning, Pat');
29
- expect(greeting(undefined, new Date(2026, 10, 4, 14))).toBe('Good afternoon');
30
- expect(greeting(' ', new Date(2026, 10, 4, 20))).toBe('Good evening');
31
- expect(greeting('Sam', new Date(2026, 10, 4, 2))).toBe('Good evening, Sam');
32
- });
33
- it('sends the text, or the label without one', () => {
34
- expect(suggestionText({ label: 'Open lots' })).toBe('Open lots');
35
- expect(suggestionText({ label: 'Lots', text: ' List open lots ' })).toBe('List open lots');
36
- });
37
- });
38
- describe('Welcome', () => {
39
- it('shows at most four suggestions and reports the chosen one', async () => {
40
- const onselect = vi.fn();
41
- render(Welcome, {
42
- props: {
43
- name: 'Pat',
44
- title: 'Hi Pat',
45
- onselect,
46
- suggestions: ['A', 'B', 'C', 'D', 'E'].map((label) => ({ label, text: `Do ${label}` })),
47
- },
48
- });
49
- expect(screen.getByRole('heading', { name: 'Hi Pat' })).toBeTruthy();
50
- const list = screen.getByRole('list', { name: 'Suggestions' });
51
- expect(list.querySelectorAll('button')).toHaveLength(4);
52
- await userEvent.click(screen.getByRole('button', { name: 'B' }));
53
- expect(onselect).toHaveBeenCalledWith('Do B', expect.objectContaining({ label: 'B' }));
54
- });
55
- });
56
- describe('AgentChat empty states', () => {
57
- it('welcomes a new chat and sends a chosen suggestion', async () => {
58
- const client = fakeClient();
59
- render(AgentChat, {
60
- props: {
61
- ...base,
62
- client: client,
63
- userName: 'Pat Lee',
64
- suggestions: [{ label: 'Open lots', text: 'List the open lots' }],
65
- },
66
- });
67
- expect(screen.getByRole('heading', { name: /, Pat$/ })).toBeTruthy();
68
- await userEvent.click(screen.getByRole('button', { name: 'Open lots' }));
69
- await waitFor(() => expect(client.streamChat).toHaveBeenCalledOnce());
70
- expect(client.streamChat.mock.calls[0][0]).toMatchObject({ message: 'List the open lots' });
71
- expect((await screen.findAllByText('Sure.')).length).toBeGreaterThan(0);
72
- expect(document.querySelector('[data-agent-welcome]')).toBeNull();
73
- });
74
- it('shows a skeleton, not the welcome, while a conversation loads', async () => {
75
- let resolve;
76
- const client = fakeClient(vi.fn(() => new Promise((r) => (resolve = r))));
77
- render(AgentChat, { props: { ...base, client: client, conversationId: 'c-9' } });
78
- expect(document.querySelector('[data-agent-loading]')).toBeTruthy();
79
- expect(document.querySelector('[data-agent-welcome]')).toBeNull();
80
- resolve({
81
- conversation: { conversationId: 'c-9', activeRequestId: null },
82
- messages: [
83
- {
84
- id: 'u-1',
85
- role: 'user',
86
- content: [{ type: 'text', text: 'Earlier question' }],
87
- createdAt: Date.now(),
88
- },
89
- ],
90
- });
91
- expect(await screen.findByText('Earlier question')).toBeTruthy();
92
- expect(document.querySelector('[data-agent-loading]')).toBeNull();
93
- });
94
- it('explains a failed load and retries it', async () => {
95
- const getConversation = vi
96
- .fn()
97
- .mockRejectedValueOnce(new Error('Network down'))
98
- .mockResolvedValueOnce({
99
- conversation: { conversationId: 'c-9', activeRequestId: null },
100
- messages: [],
101
- });
102
- render(AgentChat, {
103
- props: { ...base, client: fakeClient(getConversation), conversationId: 'c-9' },
104
- });
105
- expect(await screen.findByText('Could not load this conversation.')).toBeTruthy();
106
- expect(document.querySelector('[data-agent-welcome]')).toBeNull();
107
- // No automatic retry loop.
108
- await new Promise((r) => setTimeout(r, 50));
109
- expect(getConversation).toHaveBeenCalledOnce();
110
- await userEvent.click(screen.getByRole('button', { name: /Try again/ }));
111
- await waitFor(() => expect(getConversation).toHaveBeenCalledTimes(2));
112
- await waitFor(() => expect(screen.queryByText('Could not load this conversation.')).toBeNull());
113
- expect(document.querySelector('[data-agent-welcome]')).toBeTruthy();
114
- });
115
- it('renders the welcome when the host gives no empty snippet', () => {
116
- render(AgentChat, { props: { ...base, client: fakeClient() } });
117
- expect(document.querySelector('[data-agent-welcome]')).toBeTruthy();
118
- });
119
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=composer-keys.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"composer-keys.test.d.ts","sourceRoot":"","sources":["../src/lib/composer-keys.test.ts"],"names":[],"mappings":""}
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { composerKeyAction } from './composer-keys.js';
3
- describe('composerKeyAction', () => {
4
- it('sends on Enter and keeps Shift+Enter for a newline', () => {
5
- expect(composerKeyAction({ key: 'Enter' }, false)).toBe('send');
6
- expect(composerKeyAction({ key: 'Enter', shiftKey: true }, false)).toBeNull();
7
- });
8
- it('ignores Enter that confirms an IME candidate', () => {
9
- expect(composerKeyAction({ key: 'Enter', isComposing: true }, false)).toBeNull();
10
- expect(composerKeyAction({ key: 'Enter', keyCode: 229 }, false)).toBeNull();
11
- });
12
- it('stops a running reply on Escape only while streaming', () => {
13
- expect(composerKeyAction({ key: 'Escape' }, true)).toBe('cancel');
14
- expect(composerKeyAction({ key: 'Escape' }, false)).toBeNull();
15
- });
16
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=markdown.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"markdown.test.d.ts","sourceRoot":"","sources":["../src/lib/markdown.test.ts"],"names":[],"mappings":""}
@@ -1,35 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { isAllowedImageUrl, isSafeUrl, parseMarkdown } from './markdown.js';
3
- describe('parseMarkdown', () => {
4
- it('renders common blocks and sanitizes', () => {
5
- const html = parseMarkdown('# Title\n\nSome **bold** and `code`.\n\n- a\n- b\n\n| h | i |\n|---|---|\n| 1 | 2 |');
6
- expect(html).toContain('<h2>Title</h2>');
7
- expect(html).toContain('<strong>bold</strong>');
8
- expect(html).toContain('<code>code</code>');
9
- expect(html).toContain('<li>a</li>');
10
- expect(html).toContain('<table class="agent-table">');
11
- });
12
- it('strips scripts, inline styles and foreign classes', () => {
13
- const html = parseMarkdown('hi <script>alert(1)</script> <b style="position:fixed" class="evil">x</b>');
14
- expect(html).not.toContain('<script');
15
- expect(html).not.toMatch(/<[a-z]+[^>]*style=/);
16
- expect(html).not.toMatch(/<[a-z]+[^>]*class="evil"/);
17
- expect(html).not.toMatch(/<b[\s>]/);
18
- });
19
- it('allows images only from allowed hosts and blocks unsafe links', () => {
20
- expect(parseMarkdown('![pix](https://attacker.example/p.gif?leak=1)')).not.toContain('<img');
21
- expect(parseMarkdown('![pix](https://images.classichomes.com/a.png)')).toContain('<img');
22
- expect(parseMarkdown('[x](javascript:alert(1))')).not.toContain('<a');
23
- const ext = parseMarkdown('[docs](https://developers.cloudflare.com/)');
24
- expect(ext).toContain('target="_blank"');
25
- expect(ext).toContain('rel="noopener noreferrer"');
26
- });
27
- it('keeps code blocks verbatim and renders task lists', () => {
28
- const html = parseMarkdown('```ts\nconst a = "<b>";\n```\n\n- [x] done\n- [ ] todo');
29
- expect(html).toContain('agent-lang-ts');
30
- expect(html).toContain('&lt;b&gt;');
31
- expect(html).toMatch(/<input[^>]*type="checkbox"[^>]*checked/);
32
- expect(isSafeUrl('javascript%3Aalert(1)')).toBe(false);
33
- expect(isAllowedImageUrl('/api/files/f1')).toBe(true);
34
- });
35
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=reducer.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"reducer.test.d.ts","sourceRoot":"","sources":["../src/lib/reducer.test.ts"],"names":[],"mappings":""}