@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,84 +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 { ChatSession } from '../session.svelte.js';
5
- import AgentChat from './AgentChat.svelte';
6
- afterEach(() => cleanup());
7
- const agents = [
8
- { key: 'classic-agent', name: 'Classic Agent' },
9
- { key: 'elevation-studio', name: 'Elevation Studio' },
10
- ];
11
- function fakeClient() {
12
- return {
13
- fileUrl: (id) => `#${id}`,
14
- uploadFile: vi.fn(),
15
- cancel: vi.fn(),
16
- resumeTurn: vi.fn(),
17
- sendFeedback: vi.fn(),
18
- getConversation: vi.fn().mockResolvedValue({
19
- conversation: { conversationId: 'c-9', agent: 'elevation-studio' },
20
- messages: [],
21
- files: [],
22
- }),
23
- streamChat: vi.fn(async (req, opts) => {
24
- [
25
- { type: 'start', conversationId: 'c-1', mode: 'live' },
26
- { type: 'delta', text: 'Hello' },
27
- { type: 'done', finishReason: 'stop' },
28
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
29
- return { kind: 'done' };
30
- }),
31
- };
32
- }
33
- const base = { sandboxOrigin: 'https://frame.test' };
34
- describe('ChatSession agent', () => {
35
- it('sends the chosen agent and adopts a loaded conversation’s agent', async () => {
36
- const client = fakeClient();
37
- const session = new ChatSession({ client: client });
38
- expect(session.agent).toBe('classic-agent');
39
- session.agent = 'elevation-studio';
40
- await session.send('hi');
41
- expect(client.streamChat.mock.calls[0][0]).toMatchObject({ agent: 'elevation-studio' });
42
- const other = new ChatSession({ client: client, conversationId: 'c-9' });
43
- await other.load('c-9');
44
- expect(other.agent).toBe('elevation-studio');
45
- });
46
- });
47
- describe('AgentChat agent picker', () => {
48
- it('offers a picker for a new conversation only, and hides it after the first message', async () => {
49
- const user = userEvent.setup();
50
- const client = fakeClient();
51
- const onagentchange = vi.fn();
52
- render(AgentChat, {
53
- props: { ...base, client: client, agents, onagentchange },
54
- });
55
- expect(onagentchange).toHaveBeenLastCalledWith('classic-agent');
56
- const picker = screen.getByRole('button', { name: 'Agent' });
57
- await user.click(picker);
58
- await user.click(await screen.findByRole('option', { name: 'Elevation Studio' }));
59
- await waitFor(() => expect(onagentchange).toHaveBeenLastCalledWith('elevation-studio'));
60
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
61
- await screen.findAllByText('Hello');
62
- expect(client.streamChat.mock.calls[0][0]).toMatchObject({ agent: 'elevation-studio' });
63
- expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
64
- });
65
- it('has no picker with a single agent or on an existing conversation', async () => {
66
- const { unmount } = render(AgentChat, {
67
- props: { ...base, client: fakeClient(), agents: agents.slice(0, 1) },
68
- });
69
- expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
70
- unmount();
71
- const onagentchange = vi.fn();
72
- render(AgentChat, {
73
- props: {
74
- ...base,
75
- client: fakeClient(),
76
- agents,
77
- conversationId: 'c-9',
78
- onagentchange,
79
- },
80
- });
81
- expect(screen.queryByRole('button', { name: 'Agent' })).toBeNull();
82
- await waitFor(() => expect(onagentchange).toHaveBeenLastCalledWith('elevation-studio'));
83
- });
84
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=composer.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"composer.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/composer.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,162 +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 { LIMITS } from '@classic-homes/agent-contracts';
5
- import { promptMenuKeyAction } from '../composer-keys.js';
6
- import { filterPrompts, promptQuery } from '../composer-prompts.js';
7
- import { formatDuration, groupToolRuns, humanizeToolName } from '../message-format.js';
8
- import Composer from './Composer.svelte';
9
- import ToolTimeline from './ToolTimeline.svelte';
10
- afterEach(() => cleanup());
11
- const prompts = [
12
- {
13
- id: '1',
14
- label: 'Weekly recap',
15
- description: 'Summary for a community',
16
- text: 'Recap {{community}}',
17
- },
18
- { id: '2', label: 'Lot report', text: async () => 'Report on lot {{lot}}' },
19
- { id: '3', label: 'Draft email', description: 'Weekly buyer update', text: 'Email' },
20
- ];
21
- describe('prompt menu helpers', () => {
22
- it('reads the query only from a bare /command', () => {
23
- expect(promptQuery('/')).toBe('');
24
- expect(promptQuery('/wee')).toBe('wee');
25
- expect(promptQuery('/weekly recap')).toBeNull();
26
- expect(promptQuery('hello /x')).toBeNull();
27
- });
28
- it('ranks label matches over description matches', () => {
29
- expect(filterPrompts(prompts, 'weekly').map((p) => p.id)).toEqual(['1', '3']);
30
- expect(filterPrompts(prompts, 'lot').map((p) => p.id)).toEqual(['2']);
31
- expect(filterPrompts(prompts, '')).toHaveLength(3);
32
- });
33
- it('maps keys while the menu is open', () => {
34
- expect(promptMenuKeyAction({ key: 'ArrowDown' })).toBe('next');
35
- expect(promptMenuKeyAction({ key: 'Enter' })).toBe('choose');
36
- expect(promptMenuKeyAction({ key: 'Enter', shiftKey: true })).toBeNull();
37
- expect(promptMenuKeyAction({ key: 'Enter', isComposing: true })).toBeNull();
38
- expect(promptMenuKeyAction({ key: 'Escape' })).toBe('close');
39
- });
40
- });
41
- describe('tool helpers', () => {
42
- it('humanizes names and durations', () => {
43
- expect(humanizeToolName('hotsheet/list_plans')).toBe('List plans');
44
- expect(humanizeToolName('getLotDetails')).toBe('Get lot details');
45
- expect(formatDuration(640)).toBe('640 ms');
46
- expect(formatDuration(1234)).toBe('1.2 s');
47
- expect(formatDuration(75_000)).toBe('1 min 15 s');
48
- });
49
- it('groups calls under their delegation', () => {
50
- const t = (callId, runId) => ({ callId, runId });
51
- const groups = groupToolRuns([t('a', 'root'), t('b', 'child-1'), t('c', 'root')], [{ child: 'research', childRunId: 'child-1', status: 'end' }]);
52
- expect(groups.map((g) => [g.delegation?.child, g.tools.map((x) => x.callId)])).toEqual([
53
- [undefined, ['a', 'c']],
54
- ['research', ['b']],
55
- ]);
56
- });
57
- });
58
- describe('Composer', () => {
59
- it('inserts a saved prompt from the / menu with the keyboard', async () => {
60
- const user = userEvent.setup();
61
- render(Composer, { props: { prompts } });
62
- const box = screen.getByRole('textbox', { name: 'Message' });
63
- await user.type(box, '/lot');
64
- const listbox = await screen.findByRole('listbox', { name: 'Saved prompts' });
65
- expect(listbox.textContent).toContain('Lot report');
66
- await user.keyboard('{Enter}');
67
- await waitFor(() => expect(box.value).toBe('Report on lot {{lot}}'));
68
- expect(screen.queryByRole('listbox')).toBeNull();
69
- });
70
- it('closes the menu on Escape and keeps the draft', async () => {
71
- const user = userEvent.setup();
72
- render(Composer, { props: { prompts } });
73
- const box = screen.getByRole('textbox', { name: 'Message' });
74
- await user.type(box, '/');
75
- await screen.findByRole('listbox');
76
- await user.keyboard('{ArrowDown}{Escape}');
77
- await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
78
- expect(box.value).toBe('/');
79
- });
80
- it('uploads pasted and dropped files and shows them while uploading', async () => {
81
- let finish = () => { };
82
- const upload = vi.fn((file) => new Promise((resolve) => {
83
- finish = resolve;
84
- void file;
85
- }));
86
- render(Composer, { props: { upload } });
87
- const box = screen.getByRole('textbox', { name: 'Message' });
88
- const file = new File(['x'], 'shot.png', { type: 'image/png' });
89
- await fireEvent.paste(box, { clipboardData: { files: [file], types: ['Files'] } });
90
- expect(upload).toHaveBeenCalledWith(file);
91
- expect(await screen.findByText('shot.png')).toBeTruthy();
92
- expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(true);
93
- finish({ fileId: 'f1', name: 'shot.png', mimeType: 'image/png', bytes: 1, status: 'uploaded' });
94
- expect(await screen.findByRole('button', { name: 'Remove shot.png' })).toBeTruthy();
95
- const card = screen.getByRole('group', { name: 'Message composer' });
96
- const dropped = new File(['y'], 'plan.pdf', { type: 'application/pdf' });
97
- const dataTransfer = { types: ['Files'], files: [dropped], dropEffect: 'none' };
98
- await fireEvent.dragEnter(card, { dataTransfer });
99
- expect(screen.getByText('Drop files to attach')).toBeTruthy();
100
- await fireEvent.drop(card, { dataTransfer });
101
- expect(upload).toHaveBeenLastCalledWith(dropped);
102
- expect(screen.queryByText('Drop files to attach')).toBeNull();
103
- });
104
- it('counts characters near the limit', async () => {
105
- const { rerender } = render(Composer, { props: { value: 'short' } });
106
- expect(screen.queryByText(/\/ 8,000/)).toBeNull();
107
- await rerender({ value: 'x'.repeat(Math.ceil(LIMITS.messageChars * 0.85)) });
108
- expect(screen.getByText(/\/ 8,000/)).toBeTruthy();
109
- });
110
- });
111
- describe('ToolTimeline', () => {
112
- const tool = (patch) => ({
113
- callId: 'c1',
114
- name: 'list_plans',
115
- server: 'hotsheet',
116
- argsPreview: '{"community":"Wolf Ranch"}',
117
- agent: 'classic-agent',
118
- runId: 'root',
119
- ...patch,
120
- });
121
- it('is open with a spinner while running and folds when the turn ends', async () => {
122
- const { rerender } = render(ToolTimeline, {
123
- props: { tools: [tool({})], delegations: [], streaming: true },
124
- });
125
- expect(screen.getAllByText('List plans…').length).toBeGreaterThan(0);
126
- expect(screen.getByText('{"community":"Wolf Ranch"}')).toBeTruthy();
127
- await rerender({
128
- tools: [tool({ status: 'ok', durationMs: 1234 })],
129
- delegations: [],
130
- streaming: false,
131
- });
132
- const trigger = screen.getByRole('button', { name: /1 tool call/ });
133
- expect(trigger.getAttribute('aria-expanded')).toBe('false');
134
- await userEvent.click(trigger);
135
- expect(screen.getByText('1.2 s')).toBeTruthy();
136
- });
137
- it('uses host labels, nests delegations and counts failures', async () => {
138
- render(ToolTimeline, {
139
- props: {
140
- streaming: false,
141
- toolLabels: { 'hotsheet/list_plans': 'Look up plans' },
142
- tools: [
143
- tool({ status: 'ok' }),
144
- tool({
145
- callId: 'c2',
146
- name: 'web_search',
147
- server: undefined,
148
- runId: 'child',
149
- status: 'error',
150
- }),
151
- ],
152
- delegations: [{ child: 'research', childRunId: 'child', status: 'end' }],
153
- },
154
- });
155
- const trigger = screen.getByRole('button', { name: /2 tool calls · via research · 1 failed/ });
156
- await userEvent.click(trigger);
157
- expect(screen.getByText('Look up plans')).toBeTruthy();
158
- expect(screen.getByText('research')).toBeTruthy();
159
- expect(screen.getByText('Web search')).toBeTruthy();
160
- expect(screen.getByText('Failed')).toBeTruthy();
161
- });
162
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=conversation-chrome.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"conversation-chrome.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/conversation-chrome.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,147 +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 ChatHeader from './ChatHeader.svelte';
5
- import ConversationSidebar from './ConversationSidebar.svelte';
6
- afterEach(() => {
7
- cleanup();
8
- // bits-ui locks body pointer events while a menu is open; an unmount mid-open leaves it set.
9
- document.body.style.pointerEvents = '';
10
- vi.useRealTimers();
11
- });
12
- function summary(id, title, extra = {}) {
13
- return {
14
- conversationId: id,
15
- title,
16
- agent: 'classic-agent',
17
- createdAt: new Date().toISOString(),
18
- updatedAt: new Date().toISOString(),
19
- ...extra,
20
- };
21
- }
22
- const conversations = [summary('c1', 'Lot 12 budget'), summary('c2', 'Warranty letters')];
23
- describe('ConversationSidebar', () => {
24
- it('debounces search and passes the trimmed query', async () => {
25
- vi.useFakeTimers({ shouldAdvanceTime: true });
26
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
27
- const onsearch = vi.fn();
28
- render(ConversationSidebar, { props: { conversations, onsearch, searchDelay: 200 } });
29
- await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), ' lot ');
30
- expect(onsearch).not.toHaveBeenCalled();
31
- await vi.advanceTimersByTimeAsync(200);
32
- expect(onsearch).toHaveBeenCalledTimes(1);
33
- expect(onsearch).toHaveBeenCalledWith('lot');
34
- });
35
- it('shows skeleton rows while the first page loads, then an empty state', async () => {
36
- const { rerender } = render(ConversationSidebar, {
37
- props: { conversations: [], loading: true },
38
- });
39
- expect(screen.getByLabelText('Loading conversations')).toBeTruthy();
40
- await rerender({ conversations: [], loading: false });
41
- expect(screen.getByText('No conversations yet')).toBeTruthy();
42
- });
43
- it('marks the selected row and a running conversation', () => {
44
- render(ConversationSidebar, {
45
- props: {
46
- conversations: [summary('c1', 'Lot 12 budget', { activeRequestId: 'r1' })],
47
- selectedId: 'c1',
48
- },
49
- });
50
- const row = screen.getByRole('button', { name: /Lot 12 budget/ });
51
- expect(row.getAttribute('aria-current')).toBe('page');
52
- expect(screen.getByText('Answering')).toBeTruthy();
53
- });
54
- it('renames from the row menu and keeps the dialog open on failure', async () => {
55
- const user = userEvent.setup();
56
- const onrename = vi
57
- .fn()
58
- .mockRejectedValueOnce(new Error('Title is taken'))
59
- .mockResolvedValueOnce(undefined);
60
- render(ConversationSidebar, { props: { conversations, onrename } });
61
- await user.click(screen.getByRole('button', { name: 'Options for Lot 12 budget' }));
62
- await user.click(await screen.findByRole('menuitem', { name: /Rename/ }));
63
- const field = await screen.findByRole('textbox', { name: 'Conversation title' });
64
- await user.clear(field);
65
- await user.type(field, 'Lot 12 final');
66
- await user.click(screen.getByRole('button', { name: 'Save' }));
67
- expect(onrename).toHaveBeenCalledWith('c1', 'Lot 12 final');
68
- expect((await screen.findByRole('alert')).textContent).toContain('Title is taken');
69
- await user.click(screen.getByRole('button', { name: 'Save' }));
70
- await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Conversation title' })).toBeNull());
71
- expect(onrename).toHaveBeenCalledTimes(2);
72
- });
73
- it('returns focus to the row menu after renaming', async () => {
74
- const user = userEvent.setup();
75
- render(ConversationSidebar, { props: { conversations, onrename: vi.fn() } });
76
- const trigger = screen.getByRole('button', { name: 'Options for Lot 12 budget' });
77
- await user.click(trigger);
78
- await user.click(await screen.findByRole('menuitem', { name: /Rename/ }));
79
- await user.click(await screen.findByRole('button', { name: 'Cancel' }));
80
- await waitFor(() => expect(document.activeElement).toBe(trigger));
81
- });
82
- it('deletes only after confirming and reports a failure', async () => {
83
- const user = userEvent.setup();
84
- const ondelete = vi.fn().mockRejectedValue(new Error('Network down'));
85
- const onerror = vi.fn();
86
- render(ConversationSidebar, { props: { conversations, ondelete, onerror } });
87
- await user.click(screen.getByRole('button', { name: 'Options for Warranty letters' }));
88
- await user.click(await screen.findByRole('menuitem', { name: /Delete/ }));
89
- expect(ondelete).not.toHaveBeenCalled();
90
- expect(await screen.findByText('Delete this conversation?')).toBeTruthy();
91
- await user.click(screen.getByRole('button', { name: 'Delete' }));
92
- await waitFor(() => expect(onerror).toHaveBeenCalledWith('Network down'));
93
- expect(ondelete).toHaveBeenCalledWith('c2');
94
- });
95
- });
96
- describe('ChatHeader', () => {
97
- it('shows the title, falls back to "New chat" and hides actions without handlers', async () => {
98
- const user = userEvent.setup();
99
- render(ChatHeader, { props: { title: ' ' } });
100
- expect(screen.getByRole('heading', { name: 'New chat' })).toBeTruthy();
101
- expect(screen.queryByRole('button', { name: 'Show conversations' })).toBeNull();
102
- // Only the shortcuts entry is left in the menu.
103
- await user.click(screen.getByRole('button', { name: 'Conversation options' }));
104
- const items = await screen.findAllByRole('menuitem');
105
- expect(items.map((i) => i.textContent?.trim())).toEqual(['Keyboard shortcuts']);
106
- });
107
- it('lists host and chat shortcuts, then returns focus to the menu button', async () => {
108
- const user = userEvent.setup();
109
- render(ChatHeader, {
110
- props: { title: 'x', shortcuts: [{ keys: ['Mod', 'Shift', 'O'], label: 'New chat' }] },
111
- });
112
- const trigger = screen.getByRole('button', { name: 'Conversation options' });
113
- await user.click(trigger);
114
- await user.click(await screen.findByRole('menuitem', { name: /Keyboard shortcuts/ }));
115
- const dialog = await screen.findByRole('dialog', { name: 'Keyboard shortcuts' });
116
- expect(dialog.textContent).toContain('New chat');
117
- expect(dialog.textContent).toContain('Stop the reply');
118
- expect(dialog.textContent).toContain('Insert a saved prompt');
119
- await user.keyboard('{Escape}');
120
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
121
- await waitFor(() => expect(document.activeElement).toBe(trigger));
122
- });
123
- const handlers = () => ({ title: 'Lot 12 budget', onrename: vi.fn(), ondelete: vi.fn() });
124
- it('offers no rename or delete before the conversation is saved', async () => {
125
- const user = userEvent.setup();
126
- render(ChatHeader, { props: { ...handlers(), onexpand: vi.fn() } });
127
- await user.click(screen.getByRole('button', { name: 'Conversation options' }));
128
- expect(await screen.findByRole('menuitem', { name: /Open full page/ })).toBeTruthy();
129
- expect(screen.queryByRole('menuitem', { name: /Rename/ })).toBeNull();
130
- expect(screen.queryByRole('menuitem', { name: /Delete/ })).toBeNull();
131
- });
132
- it('offers rename and delete for a saved conversation', async () => {
133
- const user = userEvent.setup();
134
- render(ChatHeader, { props: { ...handlers(), conversationId: 'c1' } });
135
- await user.click(screen.getByRole('button', { name: 'Conversation options' }));
136
- expect(await screen.findByRole('menuitem', { name: /Rename/ })).toBeTruthy();
137
- expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeTruthy();
138
- });
139
- it('opens the conversation rail', async () => {
140
- const user = userEvent.setup();
141
- const onopenrail = vi.fn();
142
- render(ChatHeader, { props: { title: 'x', onopenrail, agentLabel: 'Classic Agent' } });
143
- expect(screen.getByText('Classic Agent')).toBeTruthy();
144
- await user.click(screen.getByRole('button', { name: 'Show conversations' }));
145
- expect(onopenrail).toHaveBeenCalledOnce();
146
- });
147
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=feedback.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"feedback.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/feedback.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,99 +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 { ChatSession } from '../session.svelte.js';
5
- import AgentChat from './AgentChat.svelte';
6
- afterEach(() => cleanup());
7
- function fakeClient(sendFeedback = vi.fn().mockResolvedValue(undefined)) {
8
- return {
9
- fileUrl: (id) => `#${id}`,
10
- uploadFile: vi.fn(),
11
- cancel: vi.fn(),
12
- resumeTurn: vi.fn(),
13
- sendFeedback,
14
- getConversation: vi.fn().mockResolvedValue({
15
- conversation: { conversationId: 'c-1' },
16
- messages: [
17
- { id: 'u-r1', seq: 0, role: 'user', content: [{ type: 'text', text: 'Q' }], createdAt: 1 },
18
- {
19
- id: 'a-r1',
20
- seq: 1,
21
- role: 'assistant',
22
- content: [{ type: 'text', text: 'A' }],
23
- createdAt: 2,
24
- },
25
- ],
26
- files: [],
27
- feedback: { r1: 'up' },
28
- }),
29
- streamChat: vi.fn(async (req, opts) => {
30
- [
31
- { type: 'start', conversationId: 'c-1', mode: 'live' },
32
- { type: 'delta', text: 'Answer' },
33
- { type: 'done', finishReason: 'stop' },
34
- ].forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
35
- return { kind: 'done' };
36
- }),
37
- };
38
- }
39
- describe('ChatSession.rate', () => {
40
- it('loads stored ratings and sends changes', async () => {
41
- const client = fakeClient();
42
- const session = new ChatSession({ client: client });
43
- await session.load('c-1');
44
- expect(session.messages[1]).toMatchObject({ feedback: 'up' });
45
- await session.rate('r1', 'down', ' Wrong lot ');
46
- expect(client.sendFeedback).toHaveBeenCalledWith('c-1', 'r1', {
47
- rating: 'down',
48
- comment: 'Wrong lot',
49
- });
50
- expect(session.messages[1]).toMatchObject({ feedback: 'down' });
51
- await session.rate('r1', null);
52
- expect(session.messages[1]).not.toHaveProperty('feedback');
53
- });
54
- it('puts the old rating back when the runtime refuses', async () => {
55
- const client = fakeClient(vi.fn().mockRejectedValue(new Error('nope')));
56
- const session = new ChatSession({ client: client });
57
- await session.load('c-1');
58
- await expect(session.rate('r1', 'down')).rejects.toThrow('nope');
59
- expect(session.messages[1]).toMatchObject({ feedback: 'up' });
60
- });
61
- });
62
- describe('AgentChat feedback', () => {
63
- it('rates an answer, asks for a note after a thumbs down, and toggles off', async () => {
64
- const user = userEvent.setup();
65
- const client = fakeClient();
66
- render(AgentChat, { props: { client: client, sandboxOrigin: 'https://frame.test' } });
67
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
68
- await screen.findAllByText('Answer');
69
- const requestId = client.streamChat.mock.calls[0][0].requestId;
70
- const down = screen.getByRole('button', { name: 'Bad answer' });
71
- await user.click(down);
72
- expect(client.sendFeedback).toHaveBeenLastCalledWith('c-1', requestId, { rating: 'down' });
73
- expect(down.getAttribute('aria-pressed')).toBe('true');
74
- const note = await screen.findByRole('textbox', { name: 'What went wrong?' });
75
- await waitFor(() => expect(document.activeElement).toBe(note));
76
- await user.type(note, 'Old prices{Enter}');
77
- expect(client.sendFeedback).toHaveBeenLastCalledWith('c-1', requestId, {
78
- rating: 'down',
79
- comment: 'Old prices',
80
- });
81
- expect(screen.queryByRole('textbox', { name: 'What went wrong?' })).toBeNull();
82
- await user.click(screen.getByRole('button', { name: 'Bad answer' }));
83
- expect(client.sendFeedback).toHaveBeenLastCalledWith('c-1', requestId, { rating: null });
84
- expect(screen.getByRole('button', { name: 'Bad answer' }).getAttribute('aria-pressed')).toBe('false');
85
- });
86
- it('tells the host when a rating could not be saved', async () => {
87
- const user = userEvent.setup();
88
- const onnotify = vi.fn();
89
- const client = fakeClient(vi.fn().mockRejectedValue(new Error('down')));
90
- render(AgentChat, {
91
- props: { client: client, sandboxOrigin: 'https://frame.test', onnotify },
92
- });
93
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}');
94
- await screen.findAllByText('Answer');
95
- await user.click(screen.getByRole('button', { name: 'Good answer' }));
96
- await waitFor(() => expect(onnotify).toHaveBeenCalledWith(expect.stringMatching(/feedback/)));
97
- expect(screen.getByRole('button', { name: 'Good answer' }).getAttribute('aria-pressed')).toBe('false');
98
- });
99
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=live-fixes.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"live-fixes.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/live-fixes.svelte.test.ts"],"names":[],"mappings":""}
@@ -1,127 +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 TwoSheets from '../../test-fixtures/TwoSheets.svelte';
5
- import { releaseStuckBodyLock } from '../body-lock.js';
6
- import { parseMarkdown } from '../markdown.js';
7
- import { ChatSession } from '../session.svelte.js';
8
- import AgentChat from './AgentChat.svelte';
9
- import ConversationSidebar from './ConversationSidebar.svelte';
10
- import Markdown from './Markdown.svelte';
11
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
12
- afterEach(() => {
13
- cleanup();
14
- document.body.removeAttribute('style');
15
- });
16
- /** A client whose streamChat replays `events` (all in one tick) and reports done. */
17
- function fakeClient(events) {
18
- return {
19
- fileUrl: (id) => `#${id}`,
20
- uploadFile: vi.fn(),
21
- cancel: vi.fn(),
22
- getConversation: vi.fn(),
23
- resumeTurn: vi.fn(),
24
- streamChat: vi.fn(async (req, opts) => {
25
- events(req.requestId).forEach((e, seq) => opts.onEvent({ requestId: req.requestId, seq, ...e }));
26
- return { kind: 'done' };
27
- }),
28
- };
29
- }
30
- const scope = { agent: 'classic-agent', runId: 'run-1' };
31
- const turn = (requestId) => [
32
- { type: 'start', conversationId: 'c-1', mode: 'live' },
33
- { type: 'delta', text: 'Here it is.' },
34
- {
35
- type: 'artifact',
36
- ...scope,
37
- artifactId: 'a1',
38
- op: 'open',
39
- mediaType: 'text/markdown',
40
- title: 'Memo',
41
- },
42
- { type: 'artifact', ...scope, artifactId: 'a1', op: 'delta', text: '# Memo' },
43
- { type: 'artifact', ...scope, artifactId: 'a1', op: 'close' },
44
- { type: 'done', finishReason: 'stop' },
45
- ].map((e) => ({ ...e, requestId }));
46
- describe('body lock guard', () => {
47
- it('clears the lock the bits-ui race leaves behind once no modal is open', async () => {
48
- // Reproduces the race: A closes and B opens in the same tick, then B closes.
49
- const { rerender } = render(TwoSheets, { props: { a: true, b: false } });
50
- await wait(100);
51
- await rerender({ a: false, b: false });
52
- await wait(0);
53
- await rerender({ a: false, b: true });
54
- await wait(300);
55
- await rerender({ a: false, b: false });
56
- await wait(600);
57
- expect(document.body.style.pointerEvents).toBe('none');
58
- releaseStuckBodyLock(0);
59
- await wait(10);
60
- expect(document.body.style.pointerEvents).toBe('');
61
- expect(document.body.style.overflow).toBe('');
62
- });
63
- it('leaves the lock alone while a modal is open', async () => {
64
- render(TwoSheets, { props: { a: true } });
65
- await wait(300);
66
- releaseStuckBodyLock(0);
67
- await wait(10);
68
- expect(document.body.style.pointerEvents).toBe('none');
69
- });
70
- });
71
- describe('ChatSession onTurnEnd', () => {
72
- it('reports the finished turn with its conversation and status', async () => {
73
- const onTurnEnd = vi.fn();
74
- const client = fakeClient(turn);
75
- const session = new ChatSession({ client: client, onTurnEnd });
76
- await session.send('hi');
77
- expect(onTurnEnd).toHaveBeenCalledOnce();
78
- expect(onTurnEnd.mock.calls[0][0]).toMatchObject({ conversationId: 'c-1', status: 'done' });
79
- });
80
- });
81
- describe('AgentChat artifacts', () => {
82
- it('opens an artifact that starts and finishes within one tick', async () => {
83
- const user = userEvent.setup();
84
- const onturnend = vi.fn();
85
- render(AgentChat, {
86
- props: { client: fakeClient(turn), sandboxOrigin: 'https://frame.test', onturnend },
87
- });
88
- await user.type(screen.getByRole('textbox', { name: 'Message' }), 'memo{Enter}');
89
- // jsdom has no wide viewport, so the artifact opens in the sheet.
90
- expect(await screen.findByRole('dialog', { name: 'Memo' })).toBeTruthy();
91
- await waitFor(() => expect(onturnend).toHaveBeenCalledOnce());
92
- });
93
- });
94
- describe('rename dialog', () => {
95
- it('starts in the title field with the title selected', async () => {
96
- const user = userEvent.setup();
97
- const now = new Date().toISOString();
98
- render(ConversationSidebar, {
99
- props: {
100
- conversations: [
101
- {
102
- conversationId: 'c1',
103
- agent: 'classic-agent',
104
- title: 'Lot 12',
105
- createdAt: now,
106
- updatedAt: now,
107
- },
108
- ],
109
- onrename: vi.fn(),
110
- },
111
- });
112
- await user.click(screen.getByRole('button', { name: 'Options for Lot 12' }));
113
- await user.click(await screen.findByRole('menuitem', { name: /Rename/ }));
114
- const field = await screen.findByRole('textbox', { name: 'Conversation title' });
115
- await waitFor(() => expect(document.activeElement).toBe(field));
116
- const input = field;
117
- expect([input.selectionStart, input.selectionEnd]).toEqual([0, 'Lot 12'.length]);
118
- });
119
- });
120
- describe('Markdown tables', () => {
121
- it('keep words whole so wide tables scroll instead of breaking numbers', () => {
122
- const { container } = render(Markdown, {
123
- props: { html: parseMarkdown('| a | b |\n| - | - |\n| $1,075,000 | x |') },
124
- });
125
- expect(container.firstElementChild.className).toContain('[&_table]:[overflow-wrap:normal]');
126
- });
127
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=markdown-prose.svelte.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"markdown-prose.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/markdown-prose.svelte.test.ts"],"names":[],"mappings":""}