@happyvertical/smrt-chat 0.51.5 → 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.
- package/AGENTS.md +19 -0
- package/dist/index.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +5 -5
- package/dist/svelte/components/agent/ToolCallDisplay.svelte +103 -7
- package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts +18 -0
- package/dist/svelte/components/agent/ToolCallDisplay.svelte.d.ts.map +1 -1
- package/dist/svelte/components/agent/__tests__/ToolCallDisplay.test.js +100 -9
- package/dist/svelte/components/assistant/AssistantComposer.svelte +354 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts +20 -0
- package/dist/svelte/components/assistant/AssistantComposer.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte +534 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts +30 -0
- package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte +136 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts +15 -0
- package/dist/svelte/components/assistant/AssistantThreadList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/__tests__/AssistantComposer.test.js +75 -0
- package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +395 -0
- package/dist/svelte/components/assistant/__tests__/AssistantThreadList.test.js +45 -0
- package/dist/svelte/components/assistant/__tests__/action-status.test.js +25 -0
- package/dist/svelte/components/assistant/__tests__/assistant-transport.test.js +253 -0
- package/dist/svelte/components/assistant/__tests__/attachment-href.test.js +31 -0
- package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +2492 -0
- package/dist/svelte/components/assistant/action-status.d.ts +14 -0
- package/dist/svelte/components/assistant/action-status.d.ts.map +1 -0
- package/dist/svelte/components/assistant/action-status.js +7 -0
- package/dist/svelte/components/assistant/assistant-transport.d.ts +239 -0
- package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -0
- package/dist/svelte/components/assistant/assistant-transport.js +306 -0
- package/dist/svelte/components/assistant/attachment-href.d.ts +23 -0
- package/dist/svelte/components/assistant/attachment-href.d.ts.map +1 -0
- package/dist/svelte/components/assistant/attachment-href.js +36 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +172 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -0
- package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +0 -0
- package/dist/svelte/components/shared/ModelPicker.svelte +84 -0
- package/dist/svelte/components/shared/ModelPicker.svelte.d.ts +26 -0
- package/dist/svelte/components/shared/ModelPicker.svelte.d.ts.map +1 -0
- package/dist/svelte/components/shared/__tests__/ModelPicker.test.js +36 -0
- package/dist/svelte/i18n.d.ts +21 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +25 -0
- package/dist/svelte/index.d.ts +7 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +12 -0
- 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
|
+
});
|