@happyvertical/smrt-chat 0.51.4 → 0.51.6

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 (47) hide show
  1. package/AGENTS.md +19 -0
  2. package/dist/index.js +1 -1
  3. package/dist/manifest.json +1 -1
  4. package/dist/smrt-knowledge.json +5 -5
  5. package/dist/svelte/components/agent/ToolCallDisplay.svelte +103 -7
  6. package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts +18 -0
  7. package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts.map +1 -1
  8. package/dist/svelte/components/agent/__tests__/ToolCallDisplay.test.js +100 -9
  9. package/dist/svelte/components/assistant/AssistantComposer.svelte +354 -0
  10. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +20 -0
  11. package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -0
  12. package/dist/svelte/components/assistant/AssistantDock.svelte +534 -0
  13. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +30 -0
  14. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -0
  15. package/dist/svelte/components/assistant/AssistantThreadList.svelte +136 -0
  16. package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts +15 -0
  17. package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts.map +1 -0
  18. package/dist/svelte/components/assistant/__tests__/AssistantComposer.test.js +75 -0
  19. package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +395 -0
  20. package/dist/svelte/components/assistant/__tests__/AssistantThreadList.test.js +45 -0
  21. package/dist/svelte/components/assistant/__tests__/action-status.test.js +25 -0
  22. package/dist/svelte/components/assistant/__tests__/assistant-transport.test.js +253 -0
  23. package/dist/svelte/components/assistant/__tests__/attachment-href.test.js +31 -0
  24. package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +2492 -0
  25. package/dist/svelte/components/assistant/action-status.d.ts +14 -0
  26. package/dist/svelte/components/assistant/action-status.d.ts.map +1 -0
  27. package/dist/svelte/components/assistant/action-status.js +7 -0
  28. package/dist/svelte/components/assistant/assistant-transport.d.ts +239 -0
  29. package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -0
  30. package/dist/svelte/components/assistant/assistant-transport.js +306 -0
  31. package/dist/svelte/components/assistant/attachment-href.d.ts +23 -0
  32. package/dist/svelte/components/assistant/attachment-href.d.ts.map +1 -0
  33. package/dist/svelte/components/assistant/attachment-href.js +36 -0
  34. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +172 -0
  35. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -0
  36. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
  37. package/dist/svelte/components/shared/ModelPicker.svelte +84 -0
  38. package/dist/svelte/components/shared/ModelPicker.svelte.d.ts +26 -0
  39. package/dist/svelte/components/shared/ModelPicker.svelte.d.ts.map +1 -0
  40. package/dist/svelte/components/shared/__tests__/ModelPicker.test.js +36 -0
  41. package/dist/svelte/i18n.d.ts +21 -0
  42. package/dist/svelte/i18n.d.ts.map +1 -1
  43. package/dist/svelte/i18n.js +25 -0
  44. package/dist/svelte/index.d.ts +7 -0
  45. package/dist/svelte/index.d.ts.map +1 -1
  46. package/dist/svelte/index.js +12 -0
  47. package/package.json +11 -11
@@ -0,0 +1,136 @@
1
+ <script lang="ts">
2
+ /**
3
+ * AssistantThreadList - sidebar list of assistant threads (#2904).
4
+ */
5
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
6
+ import { Button } from '@happyvertical/smrt-ui/ui';
7
+ import { M } from '../../i18n.js';
8
+ import type { AssistantThreadSummary } from './assistant-transport.js';
9
+
10
+ const { t } = useI18n();
11
+
12
+ export interface Props {
13
+ /** Threads to list, most-recent-first order left to the caller. */
14
+ threads: AssistantThreadSummary[];
15
+ /** The currently open thread id, highlighted and `aria-current`. */
16
+ activeThreadId?: string | null;
17
+ /** Fired with a thread's id when the user clicks its row. */
18
+ onselect: (threadId: string) => void;
19
+ /** Shown as a "+ New conversation" row when present; fired on click. */
20
+ oncreate?: () => void;
21
+ }
22
+
23
+ const { threads, activeThreadId = null, onselect, oncreate }: Props = $props();
24
+ </script>
25
+
26
+ <nav
27
+ class="assistant-thread-list"
28
+ aria-label={t(M['chat.assistant_thread_list.conversations_label'])}
29
+ >
30
+ {#if oncreate}
31
+ <Button
32
+ type="button"
33
+ variant="ghost"
34
+ class="assistant-thread-list-new"
35
+ onclick={oncreate}
36
+ >
37
+ {t(M['chat.assistant_thread_list.new_conversation'])}
38
+ </Button>
39
+ {/if}
40
+ <ul>
41
+ {#each threads as thread (thread.id)}
42
+ <li>
43
+ <Button
44
+ type="button"
45
+ variant="ghost"
46
+ class={thread.id === activeThreadId
47
+ ? 'assistant-thread-list-item active'
48
+ : 'assistant-thread-list-item'}
49
+ onclick={() => onselect(thread.id)}
50
+ aria-current={thread.id === activeThreadId ? 'true' : undefined}
51
+ >
52
+ <span class="title">
53
+ {thread.title || t(M['chat.assistant_thread_list.untitled'])}
54
+ </span>
55
+ {#if thread.messageCount > 0}
56
+ <span class="count">{thread.messageCount}</span>
57
+ {/if}
58
+ </Button>
59
+ </li>
60
+ {/each}
61
+ </ul>
62
+ </nav>
63
+
64
+ <style>
65
+ .assistant-thread-list {
66
+ display: flex;
67
+ flex-direction: column;
68
+ border-right: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
69
+ background: var(--smrt-color-surface-container-low, #f7f7fb);
70
+ min-width: 160px;
71
+ overflow-y: auto;
72
+ }
73
+
74
+ :global(.assistant-thread-list-new) {
75
+ margin: var(--smrt-spacing-2, 8px);
76
+ padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
77
+ border: 1px dashed var(--smrt-color-outline-variant, #c4c6cf);
78
+ border-radius: var(--smrt-radius-medium, 8px);
79
+ background: transparent;
80
+ color: var(--smrt-color-primary, #005ac1);
81
+ font: var(--smrt-typography-label-medium-font, 500 0.8125rem/1.3 sans-serif);
82
+ cursor: pointer;
83
+ text-align: left;
84
+ }
85
+
86
+ :global(.assistant-thread-list-new:hover) {
87
+ background: var(--smrt-color-surface-container, #f0f0f4);
88
+ }
89
+
90
+ .assistant-thread-list ul {
91
+ list-style: none;
92
+ margin: 0;
93
+ padding: 0 var(--smrt-spacing-2, 8px) var(--smrt-spacing-2, 8px);
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: var(--smrt-spacing-1, 4px);
97
+ }
98
+
99
+ :global(.assistant-thread-list-item) {
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: space-between;
103
+ gap: var(--smrt-spacing-2, 8px);
104
+ width: 100%;
105
+ padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
106
+ border: none;
107
+ border-radius: var(--smrt-radius-medium, 8px);
108
+ background: transparent;
109
+ color: var(--smrt-color-on-surface, #1a1c1e);
110
+ font: var(--smrt-typography-body-medium-font, 0.875rem/1.4 sans-serif);
111
+ text-align: left;
112
+ cursor: pointer;
113
+ }
114
+
115
+ :global(.assistant-thread-list-item:hover) {
116
+ background: var(--smrt-color-surface-container, #f0f0f4);
117
+ }
118
+
119
+ :global(.assistant-thread-list-item.active) {
120
+ background: var(--smrt-color-secondary-container, #d7e3f8);
121
+ color: var(--smrt-color-on-secondary-container, #0e1d31);
122
+ font-weight: var(--smrt-typography-weight-semibold, 600);
123
+ }
124
+
125
+ .title {
126
+ overflow: hidden;
127
+ text-overflow: ellipsis;
128
+ white-space: nowrap;
129
+ }
130
+
131
+ .count {
132
+ flex-shrink: 0;
133
+ font: var(--smrt-typography-label-small-font, 500 0.6875rem/1 sans-serif);
134
+ color: var(--smrt-color-on-surface-variant, #43474e);
135
+ }
136
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { AssistantThreadSummary } from './assistant-transport.js';
2
+ export interface Props {
3
+ /** Threads to list, most-recent-first order left to the caller. */
4
+ threads: AssistantThreadSummary[];
5
+ /** The currently open thread id, highlighted and `aria-current`. */
6
+ activeThreadId?: string | null;
7
+ /** Fired with a thread's id when the user clicks its row. */
8
+ onselect: (threadId: string) => void;
9
+ /** Shown as a "+ New conversation" row when present; fired on click. */
10
+ oncreate?: () => void;
11
+ }
12
+ declare const AssistantThreadList: import("svelte").Component<Props, {}, "">;
13
+ type AssistantThreadList = ReturnType<typeof AssistantThreadList>;
14
+ export default AssistantThreadList;
15
+ //# sourceMappingURL=AssistantThreadList.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssistantThreadList.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantThreadList.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB,mEAAmE;IACnE,OAAO,EAAE,sBAAsB,EAAE,CAAC;IAClC,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AA0CD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component-level coverage for AssistantComposer (#2904 review finding 4).
4
+ *
5
+ * Before this fix, `handleSend` cleared `content`/`stagedAttachments`
6
+ * synchronously before `onsend`'s promise settled, so a transport failure
7
+ * discarded the user's typed message with no visible error.
8
+ */
9
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import AssistantComposer from '../AssistantComposer.svelte';
12
+ function fileInput(container) {
13
+ const input = container.querySelector('input[type="file"]');
14
+ if (!input)
15
+ throw new Error('file input not found');
16
+ return input;
17
+ }
18
+ const png = (name = 'photo.png') => new File(['data'], name, { type: 'image/png' });
19
+ describe('AssistantComposer', () => {
20
+ it('keeps the draft and shows an inline error when onsend rejects', async () => {
21
+ const onsend = vi.fn().mockRejectedValue(new Error('network down'));
22
+ const onupload = vi.fn();
23
+ render(AssistantComposer, { props: { onsend, onupload } });
24
+ const textarea = screen.getByLabelText('Message');
25
+ await userEvent.type(textarea, 'hello there');
26
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
27
+ expect(onsend).toHaveBeenCalledWith('hello there', []);
28
+ // The draft text must still be in the textarea — not cleared.
29
+ expect(textarea).toHaveValue('hello there');
30
+ expect(await screen.findByText(/Could not send: network down/i)).toBeInTheDocument();
31
+ });
32
+ it('clears the draft only after onsend resolves successfully', async () => {
33
+ const onsend = vi.fn().mockResolvedValue(undefined);
34
+ const onupload = vi.fn();
35
+ render(AssistantComposer, { props: { onsend, onupload } });
36
+ const textarea = screen.getByLabelText('Message');
37
+ await userEvent.type(textarea, 'hi');
38
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
39
+ expect(onsend).toHaveBeenCalledWith('hi', []);
40
+ expect(textarea).toHaveValue('');
41
+ expect(screen.queryByText(/Could not send/i)).not.toBeInTheDocument();
42
+ });
43
+ // Cycle-2 third final: a rejecting onupload previously had no catch
44
+ // anywhere in handleFileChange, so the chip row never updated and no
45
+ // error appeared — the rejection escaped as an unhandled promise
46
+ // rejection from the DOM change event instead.
47
+ it('shows an inline error and keeps existing chips when onupload rejects', async () => {
48
+ const onsend = vi.fn();
49
+ const onupload = vi
50
+ .fn()
51
+ .mockResolvedValueOnce([{ id: 'att-1', name: 'existing.png' }])
52
+ .mockRejectedValueOnce(new Error('no writeEndpoint configured'));
53
+ const { container } = render(AssistantComposer, {
54
+ props: { onsend, onupload },
55
+ });
56
+ // First upload succeeds and stages a chip.
57
+ await userEvent.upload(fileInput(container), png('existing.png'));
58
+ expect(await screen.findByText('existing.png')).toBeInTheDocument();
59
+ // Second upload rejects: the existing chip must remain, and the
60
+ // rejection must show as an inline error.
61
+ await userEvent.upload(fileInput(container), png('second.png'));
62
+ expect(await screen.findByText(/Could not attach file: no writeEndpoint configured/i)).toBeInTheDocument();
63
+ expect(screen.getByText('existing.png')).toBeInTheDocument();
64
+ expect(screen.queryByText('second.png')).not.toBeInTheDocument();
65
+ });
66
+ // Cycle-3 second final finding 2: the hidden file input had no accessible
67
+ // name (the visually-hidden `clip: rect(0 0 0 0)` styling keeps it in the
68
+ // a11y tree, unlike ../shared/FileUpload.svelte's wrapping visible label).
69
+ it('is axe-clean', async () => {
70
+ const { container } = render(AssistantComposer, {
71
+ props: { onsend: vi.fn(), onupload: vi.fn() },
72
+ });
73
+ await expectNoA11yViolations(container);
74
+ });
75
+ });
@@ -0,0 +1,395 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component-level coverage for AssistantDock (#2904 review finding F1).
4
+ *
5
+ * The unit suite for `createAssistantDockController` and the smrt-svelte
6
+ * conformance-style integration test both drive a SEPARATE controller
7
+ * instance than the one the mounted component owns, so neither one could
8
+ * have caught F1: `AssistantDock`'s `$effect` read `$state` synchronously
9
+ * (via `startPolling` → `pendingSends`), so every real `send()` through the
10
+ * component's OWN controller re-ran the effect and its cleanup permanently
11
+ * unsubscribed the registry listener after the first message. This file
12
+ * renders the real component and drives it through its own DOM.
13
+ */
14
+ import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
15
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
16
+ import { describe, expect, it, vi } from 'vitest';
17
+ import AssistantDock from '../AssistantDock.svelte';
18
+ import { createInMemoryAssistantTransport } from '../assistant-transport.js';
19
+ const identity = {
20
+ surfaceId: 'orders',
21
+ kind: 'table',
22
+ subject: { type: 'tenant', id: 'tenant-a' },
23
+ };
24
+ const descriptor = {
25
+ version: 1,
26
+ identity,
27
+ schemaVersion: 1,
28
+ label: 'Orders',
29
+ rowKey: 'id',
30
+ columns: [{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' }],
31
+ query: {
32
+ modes: ['rows'],
33
+ projectableColumnIds: ['id'],
34
+ searchableColumnIds: [],
35
+ filterableColumnIds: [],
36
+ sortableColumnIds: [],
37
+ },
38
+ actions: [],
39
+ controls: [],
40
+ limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
41
+ };
42
+ describe('AssistantDock (mounted component)', () => {
43
+ it('loadThreads/loadModels fire exactly once per mount, and the registry subscription survives a send (F1)', async () => {
44
+ const registry = createDataSurfaceRegistry();
45
+ const transport = createInMemoryAssistantTransport({
46
+ respond: (threadId, userMessage) => ({
47
+ id: 'assistant-1',
48
+ threadId,
49
+ content: `echo: ${userMessage.content}`,
50
+ role: 'assistant',
51
+ createdAt: new Date(),
52
+ }),
53
+ });
54
+ const listThreadsSpy = vi.spyOn(transport, 'listThreads');
55
+ render(AssistantDock, { props: { transport, registry } });
56
+ expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
57
+ expect(listThreadsSpy).toHaveBeenCalledTimes(1);
58
+ // Create + open a thread through the UI, then send a real message
59
+ // through the component's OWN mounted controller.
60
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
61
+ const textarea = await screen.findByLabelText('Message');
62
+ await userEvent.type(textarea, 'hello there');
63
+ const sendButton = screen.getByRole('button', { name: 'Send' });
64
+ await userEvent.click(sendButton);
65
+ expect(await screen.findByText('echo: hello there')).toBeInTheDocument();
66
+ // F1 regression: before the fix, the send above re-ran the mount effect
67
+ // and its cleanup permanently disposed the controller (unsubscribing
68
+ // the registry listener), so a surface registered afterward was never
69
+ // discovered and the "no surfaces" notice never cleared.
70
+ registry.register({
71
+ descriptor,
72
+ getSnapshot: () => ({ revision: 1, state: {} }),
73
+ });
74
+ // Poll until the notice clears (or fail): the registry event handler
75
+ // runs synchronously, so this should resolve on the very next microtask.
76
+ for (let attempt = 0; attempt < 20; attempt += 1) {
77
+ if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
78
+ break;
79
+ }
80
+ await new Promise((resolve) => setTimeout(resolve, 10));
81
+ }
82
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
83
+ // loadThreads must still have fired only once for the mount — not once
84
+ // per send/poll re-run of a re-triggered effect.
85
+ expect(listThreadsSpy).toHaveBeenCalledTimes(1);
86
+ });
87
+ // Finding B (#2904 review, third final pass): reassigning the `registry`
88
+ // prop (a host swapping tenant/workspace context) must be observed by the
89
+ // MOUNTED component's own controller, not just a freshly-constructed one.
90
+ // The corresponding controller-level test in
91
+ // create-assistant-dock-controller.test.ts asserts the harder-to-observe
92
+ // parts (surfaces content, previewAction rejection, preview invalidation)
93
+ // directly against syncRegistry(); this test proves the DOM-visible
94
+ // surfaces-empty notice reacts to the same prop swap through the real
95
+ // component, and that F1's "mount effect runs once" guarantee still
96
+ // holds afterward.
97
+ it('re-subscribes when the registry prop is reassigned to a different instance', async () => {
98
+ const r1 = createDataSurfaceRegistry();
99
+ r1.register({
100
+ descriptor,
101
+ getSnapshot: () => ({ revision: 1, state: {} }),
102
+ });
103
+ const transport = createInMemoryAssistantTransport();
104
+ const listThreadsSpy = vi.spyOn(transport, 'listThreads');
105
+ const { rerender } = render(AssistantDock, {
106
+ props: { transport, registry: r1 },
107
+ });
108
+ // R1 has a mounted surface — the empty notice must not show.
109
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
110
+ // Reassign the prop to a DIFFERENT, empty registry instance (R2).
111
+ const r2 = createDataSurfaceRegistry();
112
+ await rerender({ transport, registry: r2 });
113
+ expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
114
+ // Registering a surface on R2 must be discovered — proves the
115
+ // subscription actually moved to R2, not just a one-time resync.
116
+ const r2Identity = { ...identity, surfaceId: 'products' };
117
+ r2.register({
118
+ descriptor: { ...descriptor, identity: r2Identity },
119
+ getSnapshot: () => ({ revision: 1, state: {} }),
120
+ });
121
+ for (let attempt = 0; attempt < 20; attempt += 1) {
122
+ if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
123
+ break;
124
+ }
125
+ await new Promise((resolve) => setTimeout(resolve, 10));
126
+ }
127
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
128
+ // F1 guarantee preserved: the registry-scoped effect must not have
129
+ // caused the SEPARATE mount effect to re-run — but Copilot PR #2919
130
+ // jAwsd's fix means the swap ITSELF now legitimately triggers exactly
131
+ // one more loadThreads() call, via syncRegistry()'s
132
+ // resetConversationStateForContextSwap() reloading from the new
133
+ // context. Two total: one from the mount effect, one from the swap.
134
+ expect(listThreadsSpy).toHaveBeenCalledTimes(2);
135
+ });
136
+ // Finding 1 (#2904 review, fresh cycle): the documented `surfaces` override
137
+ // prop did not exist on <AssistantDock> — only reachable by constructing
138
+ // the controller directly. Discovery half asserted here through the DOM:
139
+ // the override, not the registry's live contents, decides whether the
140
+ // "no surfaces" notice renders.
141
+ it("scopes discovery to the explicit `surfaces` override, ignoring the registry's live contents", async () => {
142
+ const registry = createDataSurfaceRegistry();
143
+ // Registry has ORDERS mounted, but the override is an explicit EMPTY
144
+ // list — the notice must still render "no surfaces" because the
145
+ // override, not the registry, is authoritative (surfaces is decoupled
146
+ // from what's actually registered once an override is set).
147
+ registry.register({
148
+ descriptor,
149
+ getSnapshot: () => ({ revision: 1, state: {} }),
150
+ });
151
+ const transport = createInMemoryAssistantTransport();
152
+ render(AssistantDock, {
153
+ props: { transport, registry, surfaces: [] },
154
+ });
155
+ expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
156
+ });
157
+ // Copilot PR #2919 jAwr0: `surfaces` is a NARROWING filter over the live
158
+ // registry — an override identity that isn't genuinely registered must
159
+ // NOT make discovery non-empty (that broke the documented fail-closed
160
+ // route scoping). Renamed from "the `surfaces` override alone makes
161
+ // discovery non-empty, even with nothing registered", which asserted the
162
+ // now-fixed behavior.
163
+ it('the `surfaces` override does NOT make discovery non-empty when the override identity is not registered', async () => {
164
+ const registry = createDataSurfaceRegistry(); // nothing registered
165
+ const transport = createInMemoryAssistantTransport();
166
+ render(AssistantDock, {
167
+ props: { transport, registry, surfaces: [identity] },
168
+ });
169
+ expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
170
+ });
171
+ it('the `surfaces` override makes discovery non-empty only when the identity IS also registered', async () => {
172
+ const registry = createDataSurfaceRegistry();
173
+ registry.register({
174
+ descriptor,
175
+ getSnapshot: () => ({ revision: 1, state: {} }),
176
+ });
177
+ const transport = createInMemoryAssistantTransport();
178
+ render(AssistantDock, {
179
+ props: { transport, registry, surfaces: [identity] },
180
+ });
181
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
182
+ });
183
+ // Finding 4 (#2904 review, fresh cycle): a failed mount-time listThreads
184
+ // must render as a dock-level error, not an empty, explanation-free
185
+ // thread list.
186
+ it('renders a dock-level error when the mount-time loadThreads() call rejects', async () => {
187
+ const registry = createDataSurfaceRegistry();
188
+ const transport = createInMemoryAssistantTransport();
189
+ transport.listThreads = async () => {
190
+ throw new Error('offline');
191
+ };
192
+ render(AssistantDock, { props: { transport, registry } });
193
+ expect(await screen.findByText(/Something went wrong: offline/i)).toBeInTheDocument();
194
+ });
195
+ // Cycle-2 second final finding 1: the `surfaces` override was captured
196
+ // once at construction — a mounted component's own controller never
197
+ // observed a reassignment of the prop. Drives it through the real
198
+ // component (rerender), not just the controller directly, mirroring how
199
+ // Finding B's registry-swap test complements the controller-level test.
200
+ it('re-scopes discovery and the action gate when the `surfaces` prop is reassigned, in both directions', async () => {
201
+ const registry = createDataSurfaceRegistry();
202
+ registry.register({
203
+ descriptor,
204
+ getSnapshot: () => ({ revision: 1, state: {} }),
205
+ });
206
+ const transport = createInMemoryAssistantTransport();
207
+ const productsIdentity = {
208
+ ...identity,
209
+ surfaceId: 'products',
210
+ };
211
+ // Copilot PR #2919 jAwr0: `surfaces` narrows against the live registry,
212
+ // so `products` must be genuinely registered too, or it would never
213
+ // pass the mount gate regardless of the override.
214
+ registry.register({
215
+ descriptor: { ...descriptor, identity: productsIdentity },
216
+ getSnapshot: () => ({ revision: 1, state: {} }),
217
+ });
218
+ const { rerender } = render(AssistantDock, {
219
+ props: { transport, registry, surfaces: [productsIdentity] },
220
+ });
221
+ // Override only includes `products`; the registered `orders` surface is
222
+ // gated out even though it's genuinely registered — the "no surfaces"
223
+ // notice must NOT show (the override list is non-empty).
224
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
225
+ // Narrow the override to an EMPTY list.
226
+ await rerender({ transport, registry, surfaces: [] });
227
+ expect(await screen.findByText(/No data surfaces are mounted on this route/i)).toBeInTheDocument();
228
+ // Widen back to include the registered `orders` surface — discovery
229
+ // must follow the reassignment and the notice must clear.
230
+ await rerender({ transport, registry, surfaces: [identity] });
231
+ for (let attempt = 0; attempt < 20; attempt += 1) {
232
+ if (!screen.queryByText(/No data surfaces are mounted on this route/i)) {
233
+ break;
234
+ }
235
+ await new Promise((resolve) => setTimeout(resolve, 10));
236
+ }
237
+ expect(screen.queryByText(/No data surfaces are mounted on this route/i)).not.toBeInTheDocument();
238
+ });
239
+ // Cycle-2 second final finding 1: the F1 "mount effect runs exactly once"
240
+ // guarantee must hold even with the new `surfaces`-scoped effect added
241
+ // alongside the existing `registry` one.
242
+ it('loadThreads still fires exactly once per mount when `surfaces` is reassigned', async () => {
243
+ const registry = createDataSurfaceRegistry();
244
+ const transport = createInMemoryAssistantTransport();
245
+ const listThreadsSpy = vi.spyOn(transport, 'listThreads');
246
+ const { rerender } = render(AssistantDock, {
247
+ props: { transport, registry, surfaces: [] },
248
+ });
249
+ await screen.findByText(/No data surfaces are mounted on this route/i);
250
+ await rerender({ transport, registry, surfaces: [identity] });
251
+ await rerender({ transport, registry, surfaces: [] });
252
+ expect(listThreadsSpy).toHaveBeenCalledTimes(1);
253
+ });
254
+ // Cycle-2 second final finding 2: "+ New conversation" and thread
255
+ // selection previously produced an unhandled rejection with zero
256
+ // user-visible surface when the transport failed — the exact path a
257
+ // `createSmrtAssistantTransport` without `writeEndpoint` is documented to
258
+ // hit.
259
+ it('a rejecting createThread (clicking "+ New conversation") shows the dock-level error banner', async () => {
260
+ const registry = createDataSurfaceRegistry();
261
+ const transport = createInMemoryAssistantTransport();
262
+ transport.createThread = async () => {
263
+ throw new Error('no writeEndpoint configured');
264
+ };
265
+ render(AssistantDock, { props: { transport, registry } });
266
+ await screen.findByText(/No data surfaces are mounted on this route/i);
267
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
268
+ expect(await screen.findByText(/Something went wrong: no writeEndpoint configured/i)).toBeInTheDocument();
269
+ });
270
+ // Cycle-2 third final: a rejecting uploadAttachment previously had no
271
+ // catch in AssistantDock's handleUpload, so the dock-level banner never
272
+ // reflected an attachment failure the way it does for send/thread
273
+ // failures.
274
+ it('a rejecting uploadAttachment shows the dock-level error banner', async () => {
275
+ const registry = createDataSurfaceRegistry();
276
+ const transport = createInMemoryAssistantTransport();
277
+ transport.uploadAttachment = async () => {
278
+ throw new Error('no writeEndpoint configured');
279
+ };
280
+ const { container } = render(AssistantDock, {
281
+ props: { transport, registry },
282
+ });
283
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
284
+ await screen.findByLabelText('Message');
285
+ const fileInput = container.querySelector('input[type="file"]');
286
+ if (!fileInput)
287
+ throw new Error('file input not found');
288
+ await userEvent.upload(fileInput, new File(['data'], 'photo.png', { type: 'image/png' }));
289
+ expect(await screen.findByText(/Something went wrong: no writeEndpoint configured/i)).toBeInTheDocument();
290
+ });
291
+ // Cycle-3 second final finding 1: message.attachments was populated by the
292
+ // transport but never rendered anywhere — an uploaded, sent attachment
293
+ // became permanently invisible the instant the composer's chip row
294
+ // cleared on success.
295
+ it('renders an attachment chip on a message after send()', async () => {
296
+ const registry = createDataSurfaceRegistry();
297
+ const transport = createInMemoryAssistantTransport();
298
+ const { container } = render(AssistantDock, {
299
+ props: { transport, registry },
300
+ });
301
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
302
+ const textarea = await screen.findByLabelText('Message');
303
+ const fileInput = container.querySelector('input[type="file"]');
304
+ if (!fileInput)
305
+ throw new Error('file input not found');
306
+ await userEvent.upload(fileInput, new File(['data'], 'report.pdf', { type: 'application/pdf' }));
307
+ await userEvent.type(textarea, 'here is the report');
308
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
309
+ expect(await screen.findByText('report.pdf')).toBeInTheDocument();
310
+ });
311
+ it('renders an attachment chip on a message loaded via loadMessages()', async () => {
312
+ const registry = createDataSurfaceRegistry();
313
+ const transport = createInMemoryAssistantTransport();
314
+ const originalLoadMessages = transport.loadMessages.bind(transport);
315
+ transport.loadMessages = async (threadId) => {
316
+ const existing = await originalLoadMessages(threadId);
317
+ if (existing.length > 0)
318
+ return existing;
319
+ return [
320
+ {
321
+ id: 'seeded-1',
322
+ threadId,
323
+ content: 'attached earlier',
324
+ role: 'user',
325
+ createdAt: new Date(),
326
+ attachments: [{ id: 'att-seeded', name: 'contract.docx' }],
327
+ },
328
+ ];
329
+ };
330
+ render(AssistantDock, { props: { transport, registry } });
331
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
332
+ expect(await screen.findByText('contract.docx')).toBeInTheDocument();
333
+ });
334
+ // Cycle-3 second final finding 2: a `models`-bearing transport mounts
335
+ // ModelPicker (previously untested and unnamed anywhere in this suite);
336
+ // an attachment-bearing message exercises the chip/link list added for
337
+ // finding 1 above. Neither path had ever been axe-checked.
338
+ it('is axe-clean with ModelPicker mounted and an attachment-bearing message', async () => {
339
+ const registry = createDataSurfaceRegistry();
340
+ const transport = createInMemoryAssistantTransport({
341
+ models: [
342
+ { id: 'model-a', label: 'Model A' },
343
+ { id: 'model-b', label: 'Model B' },
344
+ ],
345
+ });
346
+ const { container } = render(AssistantDock, {
347
+ props: { transport, registry },
348
+ });
349
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
350
+ const textarea = await screen.findByLabelText('Message');
351
+ const fileInput = container.querySelector('input[type="file"]');
352
+ if (!fileInput)
353
+ throw new Error('file input not found');
354
+ await userEvent.upload(fileInput, new File(['data'], 'report.pdf', { type: 'application/pdf' }));
355
+ await userEvent.type(textarea, 'here is the report');
356
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }));
357
+ await screen.findByText('report.pdf');
358
+ // ModelPicker must have mounted (its accessible name resolves).
359
+ expect(screen.getByLabelText('Model')).toBeInTheDocument();
360
+ await expectNoA11yViolations(container);
361
+ });
362
+ // Cycle-3 second final F1 addendum: attachment.url is transport-supplied
363
+ // data bound to <a href> — a javascript: URL must never render a
364
+ // clickable anchor.
365
+ it('renders a javascript: attachment URL as plain text, not an anchor', async () => {
366
+ const registry = createDataSurfaceRegistry();
367
+ const transport = createInMemoryAssistantTransport();
368
+ const originalLoadMessages = transport.loadMessages.bind(transport);
369
+ transport.loadMessages = async (threadId) => {
370
+ const existing = await originalLoadMessages(threadId);
371
+ if (existing.length > 0)
372
+ return existing;
373
+ return [
374
+ {
375
+ id: 'seeded-xss',
376
+ threadId,
377
+ content: 'attached earlier',
378
+ role: 'user',
379
+ createdAt: new Date(),
380
+ attachments: [
381
+ {
382
+ id: 'att-xss',
383
+ name: 'evil.txt',
384
+ url: 'javascript:alert(1)',
385
+ },
386
+ ],
387
+ },
388
+ ];
389
+ };
390
+ render(AssistantDock, { props: { transport, registry } });
391
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
392
+ const attachmentText = await screen.findByText('evil.txt');
393
+ expect(attachmentText.closest('a')).toBeNull();
394
+ });
395
+ });
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component-level coverage for AssistantThreadList (#2904 review, cycle-3
4
+ * second final F2 — none of the three new assistant components carried an
5
+ * axe assertion; this one had no test file at all).
6
+ */
7
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import AssistantThreadList from '../AssistantThreadList.svelte';
10
+ const threads = [
11
+ { id: 't1', title: 'Order question', isResolved: false, messageCount: 3 },
12
+ { id: 't2', title: '', isResolved: false, messageCount: 0 },
13
+ ];
14
+ describe('AssistantThreadList', () => {
15
+ it('renders each thread and fires onselect with its id', async () => {
16
+ const onselect = vi.fn();
17
+ render(AssistantThreadList, {
18
+ props: { threads, activeThreadId: 't1', onselect },
19
+ });
20
+ expect(screen.getByText('Order question')).toBeInTheDocument();
21
+ // Untitled fallback for a thread with an empty title.
22
+ expect(screen.getByText('Untitled')).toBeInTheDocument();
23
+ await userEvent.click(screen.getByText('Untitled'));
24
+ expect(onselect).toHaveBeenCalledWith('t2');
25
+ });
26
+ it('renders a "New conversation" row and fires oncreate when supplied', async () => {
27
+ const oncreate = vi.fn();
28
+ render(AssistantThreadList, {
29
+ props: { threads: [], onselect: vi.fn(), oncreate },
30
+ });
31
+ await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
32
+ expect(oncreate).toHaveBeenCalledOnce();
33
+ });
34
+ it('is axe-clean', async () => {
35
+ const { container } = render(AssistantThreadList, {
36
+ props: {
37
+ threads,
38
+ activeThreadId: 't1',
39
+ onselect: vi.fn(),
40
+ oncreate: vi.fn(),
41
+ },
42
+ });
43
+ await expectNoA11yViolations(container);
44
+ });
45
+ });