@classic-homes/agent-chat 0.2.0 → 0.3.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.
- package/README.md +14 -2
- package/dist/body-lock.d.ts +2 -0
- package/dist/body-lock.d.ts.map +1 -0
- package/dist/body-lock.js +27 -0
- package/dist/components/AgentChat.svelte +22 -9
- package/dist/components/AgentChat.svelte.d.ts +6 -0
- package/dist/components/AgentChat.svelte.d.ts.map +1 -1
- package/dist/components/ArtifactChip.svelte +45 -0
- package/dist/components/ArtifactChip.svelte.d.ts +14 -0
- package/dist/components/ArtifactChip.svelte.d.ts.map +1 -0
- package/dist/components/AssistantMessage.svelte +179 -0
- package/dist/components/AssistantMessage.svelte.d.ts +25 -0
- package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
- package/dist/components/ConversationDialogs.svelte +23 -3
- package/dist/components/ConversationDialogs.svelte.d.ts.map +1 -1
- package/dist/components/Markdown.svelte +88 -5
- package/dist/components/Markdown.svelte.d.ts +10 -1
- package/dist/components/Markdown.svelte.d.ts.map +1 -1
- package/dist/components/MessageActions.svelte +56 -0
- package/dist/components/MessageActions.svelte.d.ts +17 -0
- package/dist/components/MessageActions.svelte.d.ts.map +1 -0
- package/dist/components/MessageFiles.svelte +83 -0
- package/dist/components/MessageFiles.svelte.d.ts +14 -0
- package/dist/components/MessageFiles.svelte.d.ts.map +1 -0
- package/dist/components/MessageItem.svelte +11 -144
- package/dist/components/MessageItem.svelte.d.ts +3 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +99 -30
- package/dist/components/MessageList.svelte.d.ts +1 -0
- package/dist/components/MessageList.svelte.d.ts.map +1 -1
- package/dist/components/ProposalCard.svelte +39 -0
- package/dist/components/ProposalCard.svelte.d.ts +13 -0
- package/dist/components/ProposalCard.svelte.d.ts.map +1 -0
- package/dist/components/UserMessage.svelte +46 -0
- package/dist/components/UserMessage.svelte.d.ts +10 -0
- package/dist/components/UserMessage.svelte.d.ts.map +1 -0
- package/dist/components/live-fixes.svelte.test.d.ts +2 -0
- package/dist/components/live-fixes.svelte.test.d.ts.map +1 -0
- package/dist/components/live-fixes.svelte.test.js +127 -0
- package/dist/components/messages.svelte.test.d.ts +2 -0
- package/dist/components/messages.svelte.test.d.ts.map +1 -0
- package/dist/components/messages.svelte.test.js +202 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +12 -4
- package/dist/message-format.d.ts +30 -0
- package/dist/message-format.d.ts.map +1 -0
- package/dist/message-format.js +118 -0
- package/dist/session.svelte.d.ts +9 -0
- package/dist/session.svelte.d.ts.map +1 -1
- package/dist/session.svelte.js +6 -0
- package/package.json +3 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The user's turn: attachments and context above a right-aligned muted bubble, then the action bar.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { Icon } from '@classic-homes/theme-svelte';
|
|
7
|
+
import type { UserMessage } from '../reducer.js';
|
|
8
|
+
import { fileIcon, formatBytes } from '../message-format.js';
|
|
9
|
+
import MessageActions from './MessageActions.svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
message: UserMessage;
|
|
13
|
+
last?: boolean;
|
|
14
|
+
}
|
|
15
|
+
let { message, last = false }: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<div class="group/message flex flex-col items-end gap-1 px-4 py-2" data-role="user">
|
|
19
|
+
{#if message.attachments.length || message.contextLabel}
|
|
20
|
+
<ul class="flex max-w-[85%] flex-wrap justify-end gap-1.5" aria-label="Attached">
|
|
21
|
+
{#if message.contextLabel}
|
|
22
|
+
<li
|
|
23
|
+
class="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground"
|
|
24
|
+
>
|
|
25
|
+
<Icon name="link" size={12} />
|
|
26
|
+
{message.contextLabel}
|
|
27
|
+
</li>
|
|
28
|
+
{/if}
|
|
29
|
+
{#each message.attachments as file (file.fileId)}
|
|
30
|
+
<li
|
|
31
|
+
class="inline-flex max-w-60 items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs"
|
|
32
|
+
>
|
|
33
|
+
<Icon name={fileIcon(file.mimeType)} size={14} class="shrink-0 text-muted-foreground" />
|
|
34
|
+
<span class="truncate text-foreground">{file.name}</span>
|
|
35
|
+
<span class="shrink-0 text-muted-foreground">{formatBytes(file.bytes)}</span>
|
|
36
|
+
</li>
|
|
37
|
+
{/each}
|
|
38
|
+
</ul>
|
|
39
|
+
{/if}
|
|
40
|
+
<div
|
|
41
|
+
class="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-br-md bg-muted px-4 py-2.5 text-foreground [overflow-wrap:anywhere]"
|
|
42
|
+
>
|
|
43
|
+
{message.text}
|
|
44
|
+
</div>
|
|
45
|
+
<MessageActions text={message.text} createdAt={message.createdAt} visible={last} align="end" />
|
|
46
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { UserMessage } from '../reducer.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
message: UserMessage;
|
|
4
|
+
last?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** The user's turn: attachments and context above a right-aligned muted bubble, then the action bar. */
|
|
7
|
+
declare const UserMessage: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type UserMessage = ReturnType<typeof UserMessage>;
|
|
9
|
+
export default UserMessage;
|
|
10
|
+
//# sourceMappingURL=UserMessage.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/UserMessage.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK/C,UAAU,KAAK;IACb,OAAO,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAqCH,wGAAwG;AACxG,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-fixes.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/live-fixes.svelte.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/messages.svelte.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { parseMarkdown } from '../markdown.js';
|
|
5
|
+
import { daySeparators, describeProposal, formatBytes, formatDay } from '../message-format.js';
|
|
6
|
+
import { newAssistantMessage, newUserMessage } from '../reducer.js';
|
|
7
|
+
import AssistantMessageView from './AssistantMessage.svelte';
|
|
8
|
+
import Markdown from './Markdown.svelte';
|
|
9
|
+
import MessageList from './MessageList.svelte';
|
|
10
|
+
import UserMessageView from './UserMessage.svelte';
|
|
11
|
+
afterEach(() => cleanup());
|
|
12
|
+
const base = { sandboxOrigin: 'https://frame.test', fileUrl: (id) => `/files/${id}` };
|
|
13
|
+
const assistant = (patch = {}) => ({
|
|
14
|
+
...newAssistantMessage('r1', Date.UTC(2026, 10, 3, 15, 0)),
|
|
15
|
+
status: 'done',
|
|
16
|
+
...patch,
|
|
17
|
+
});
|
|
18
|
+
const DAY = 86_400_000;
|
|
19
|
+
describe('message formatting', () => {
|
|
20
|
+
it('formats sizes and relative days', () => {
|
|
21
|
+
expect(formatBytes(900)).toBe('900 B');
|
|
22
|
+
expect(formatBytes(2048)).toBe('2 KB');
|
|
23
|
+
expect(formatBytes(3 * 1048576)).toBe('3.0 MB');
|
|
24
|
+
const now = new Date(2026, 10, 3, 12).getTime();
|
|
25
|
+
expect(formatDay(now - 60_000, now)).toBe('Today');
|
|
26
|
+
expect(formatDay(now - DAY, now)).toBe('Yesterday');
|
|
27
|
+
expect(formatDay(now - 5 * DAY, now)).not.toMatch(/Today|Yesterday/);
|
|
28
|
+
});
|
|
29
|
+
it('marks the first message of each day', () => {
|
|
30
|
+
const t = new Date(2026, 10, 1, 10).getTime();
|
|
31
|
+
expect([...daySeparators([{ createdAt: t }, { createdAt: t + 60_000 }])]).toEqual([0]);
|
|
32
|
+
expect([...daySeparators([{ createdAt: t }, { createdAt: t + DAY }])]).toEqual([0, 1]);
|
|
33
|
+
});
|
|
34
|
+
it('describes proposals without applying them', () => {
|
|
35
|
+
const op = {
|
|
36
|
+
kind: 'excel.writeRange',
|
|
37
|
+
operationId: '00000000-0000-4000-8000-000000000001',
|
|
38
|
+
documentId: 'doc-1',
|
|
39
|
+
summary: 'Fill totals',
|
|
40
|
+
target: { sheet: 'Budget', address: 'B2:B4' },
|
|
41
|
+
write: { type: 'values', values: [[1], [2], [3]] },
|
|
42
|
+
precondition: { formulas: [[''], [''], ['']] },
|
|
43
|
+
};
|
|
44
|
+
expect(describeProposal(op)).toEqual({
|
|
45
|
+
action: 'Write values',
|
|
46
|
+
host: 'Excel',
|
|
47
|
+
target: 'Budget!B2:B4',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('markdown output', () => {
|
|
52
|
+
it('wraps tables in a scrolling container and never trusts a literal div', () => {
|
|
53
|
+
const html = parseMarkdown('| A | B |\n|---|---|\n| 1 | 2 |\n\n<div class="agent-x">hi</div>');
|
|
54
|
+
expect(html).toMatch(/<div class="agent-table-wrap"><table class="agent-table">/);
|
|
55
|
+
expect(html).toContain('<div');
|
|
56
|
+
expect(html.match(/<div/g)).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
it('marks external links', () => {
|
|
59
|
+
expect(parseMarkdown('[docs](https://example.com/)')).toContain('class="agent-external-link"');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('Markdown code blocks', () => {
|
|
63
|
+
it('adds a language header whose button copies the code', async () => {
|
|
64
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
|
66
|
+
render(Markdown, { props: { html: parseMarkdown('```ts\nconst a = 1;\n```') } });
|
|
67
|
+
const button = await screen.findByRole('button', { name: 'Copy ts code' });
|
|
68
|
+
expect(button.closest('.agent-codeblock')?.textContent).toContain('ts');
|
|
69
|
+
await fireEvent.click(button);
|
|
70
|
+
expect(writeText).toHaveBeenCalledWith('const a = 1;');
|
|
71
|
+
await waitFor(() => expect(button.textContent).toContain('Copied'));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('AssistantMessage', () => {
|
|
75
|
+
it('shows a retryable error with Try again', async () => {
|
|
76
|
+
const onretry = vi.fn();
|
|
77
|
+
render(AssistantMessageView, {
|
|
78
|
+
props: {
|
|
79
|
+
...base,
|
|
80
|
+
last: true,
|
|
81
|
+
onretry,
|
|
82
|
+
message: assistant({
|
|
83
|
+
status: 'error',
|
|
84
|
+
error: { code: 'upstream_error', message: 'Model failed.', retryable: true },
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(screen.getByText(/The agent ran into a problem/)).toBeTruthy();
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: /Try again/ }));
|
|
90
|
+
expect(onretry).toHaveBeenCalledOnce();
|
|
91
|
+
});
|
|
92
|
+
it('offers Continue on a cut-short or stopped last answer only', async () => {
|
|
93
|
+
const oncontinue = vi.fn();
|
|
94
|
+
const { rerender } = render(AssistantMessageView, {
|
|
95
|
+
props: { ...base, last: true, oncontinue, message: assistant({ status: 'incomplete' }) },
|
|
96
|
+
});
|
|
97
|
+
expect(screen.getByText('The answer was cut short.')).toBeTruthy();
|
|
98
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
|
99
|
+
expect(oncontinue).toHaveBeenCalledOnce();
|
|
100
|
+
await rerender({
|
|
101
|
+
...base,
|
|
102
|
+
last: true,
|
|
103
|
+
oncontinue,
|
|
104
|
+
message: assistant({ status: 'cancelled' }),
|
|
105
|
+
});
|
|
106
|
+
expect(screen.getByText('Stopped')).toBeTruthy();
|
|
107
|
+
expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy();
|
|
108
|
+
await rerender({
|
|
109
|
+
...base,
|
|
110
|
+
last: false,
|
|
111
|
+
oncontinue,
|
|
112
|
+
message: assistant({ status: 'cancelled' }),
|
|
113
|
+
});
|
|
114
|
+
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
it('renders artifacts, proposals and a thinking state', async () => {
|
|
117
|
+
const onopenartifact = vi.fn();
|
|
118
|
+
const { rerender } = render(AssistantMessageView, {
|
|
119
|
+
props: { ...base, streaming: true, message: assistant({ status: 'streaming' }) },
|
|
120
|
+
});
|
|
121
|
+
expect(screen.getByRole('status').textContent).toContain('Thinking');
|
|
122
|
+
await rerender({
|
|
123
|
+
...base,
|
|
124
|
+
onopenartifact,
|
|
125
|
+
message: assistant({
|
|
126
|
+
artifacts: [
|
|
127
|
+
{ artifactId: 'a1', mediaType: 'text/html', title: 'Plans', text: '', open: false },
|
|
128
|
+
{ artifactId: 'a2', mediaType: 'text/markdown', title: 'Memo', text: '', open: true },
|
|
129
|
+
],
|
|
130
|
+
proposals: [
|
|
131
|
+
{
|
|
132
|
+
kind: 'word.replaceSelection',
|
|
133
|
+
operationId: '00000000-0000-4000-8000-000000000002',
|
|
134
|
+
documentId: 'doc-1',
|
|
135
|
+
summary: 'Tighten the intro',
|
|
136
|
+
target: { targetId: 't1', expectedText: 'Old' },
|
|
137
|
+
text: 'New',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
expect(screen.queryByText('Thinking')).toBeNull();
|
|
143
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open Plans' }));
|
|
144
|
+
expect(onopenartifact).toHaveBeenCalledWith('a1');
|
|
145
|
+
expect(screen.getByRole('button', { name: 'Memo (writing)' })).toBeTruthy();
|
|
146
|
+
expect(screen.getByText('Tighten the intro')).toBeTruthy();
|
|
147
|
+
expect(screen.getByText('Apply from the Word add-in.')).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
it('has a copy action once the answer is done', () => {
|
|
150
|
+
render(AssistantMessageView, {
|
|
151
|
+
props: { ...base, message: assistant({ text: 'Hello there' }) },
|
|
152
|
+
});
|
|
153
|
+
expect(screen.getByRole('button', { name: 'Copy message' })).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('UserMessage', () => {
|
|
157
|
+
it('shows the text in a bubble with its attachments', () => {
|
|
158
|
+
const message = newUserMessage('r1', 'See attached', {
|
|
159
|
+
attachments: [
|
|
160
|
+
{
|
|
161
|
+
fileId: 'f1',
|
|
162
|
+
name: 'plan.pdf',
|
|
163
|
+
mimeType: 'application/pdf',
|
|
164
|
+
bytes: 2048,
|
|
165
|
+
status: 'uploaded',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
render(UserMessageView, { props: { message } });
|
|
170
|
+
expect(screen.getByText('See attached')).toBeTruthy();
|
|
171
|
+
expect(screen.getByText('plan.pdf')).toBeTruthy();
|
|
172
|
+
expect(screen.getByText('2 KB')).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('MessageList', () => {
|
|
176
|
+
it('labels days only when the transcript spans more than one', () => {
|
|
177
|
+
const t = Date.now();
|
|
178
|
+
const one = [newUserMessage('r1', 'hi', { now: t }), assistant({ createdAt: t })];
|
|
179
|
+
const { rerender } = render(MessageList, {
|
|
180
|
+
props: { ...base, streaming: false, messages: one },
|
|
181
|
+
});
|
|
182
|
+
expect(screen.queryByRole('separator')).toBeNull();
|
|
183
|
+
const two = [newUserMessage('r0', 'old', { now: t - 3 * DAY }), ...one];
|
|
184
|
+
void rerender({ ...base, streaming: false, messages: two });
|
|
185
|
+
return waitFor(() => expect(screen.getAllByRole('separator').map((s) => s.textContent?.trim())).toContain('Today'));
|
|
186
|
+
});
|
|
187
|
+
it('offers a jump to the latest once the reader scrolls up', async () => {
|
|
188
|
+
render(MessageList, {
|
|
189
|
+
props: { ...base, streaming: false, messages: [newUserMessage('r1', 'hi'), assistant()] },
|
|
190
|
+
});
|
|
191
|
+
const log = screen.getByRole('log');
|
|
192
|
+
Object.defineProperty(log, 'scrollHeight', { value: 2000, configurable: true });
|
|
193
|
+
Object.defineProperty(log, 'clientHeight', { value: 500, configurable: true });
|
|
194
|
+
log.scrollTop = 100;
|
|
195
|
+
await fireEvent.scroll(log);
|
|
196
|
+
const jump = await screen.findByRole('button', { name: 'Jump to the latest message' });
|
|
197
|
+
log.scrollTo = vi.fn();
|
|
198
|
+
await userEvent.click(jump);
|
|
199
|
+
expect(log.scrollTo).toHaveBeenCalled();
|
|
200
|
+
expect(screen.queryByRole('button', { name: /latest/ })).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export * from './body-lock.js';
|
|
1
2
|
export * from './composer-keys.js';
|
|
2
3
|
export * from './markdown.js';
|
|
4
|
+
export * from './message-format.js';
|
|
3
5
|
export * from './reducer.js';
|
|
4
6
|
export * from './session.svelte.js';
|
|
5
7
|
export * from './ui-frame-protocol.js';
|
|
@@ -13,4 +15,10 @@ export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
|
13
15
|
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
14
16
|
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
15
17
|
export { default as ChatHeader } from './components/ChatHeader.svelte';
|
|
18
|
+
export { default as AssistantMessage } from './components/AssistantMessage.svelte';
|
|
19
|
+
export { default as UserMessage } from './components/UserMessage.svelte';
|
|
20
|
+
export { default as MessageActions } from './components/MessageActions.svelte';
|
|
21
|
+
export { default as ArtifactChip } from './components/ArtifactChip.svelte';
|
|
22
|
+
export { default as MessageFiles } from './components/MessageFiles.svelte';
|
|
23
|
+
export { default as ProposalCard } from './components/ProposalCard.svelte';
|
|
16
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export * from './body-lock.js';
|
|
1
2
|
export * from './composer-keys.js';
|
|
2
3
|
export * from './markdown.js';
|
|
4
|
+
export * from './message-format.js';
|
|
3
5
|
export * from './reducer.js';
|
|
4
6
|
export * from './session.svelte.js';
|
|
5
7
|
export * from './ui-frame-protocol.js';
|
|
@@ -13,3 +15,9 @@ export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
|
13
15
|
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
14
16
|
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
15
17
|
export { default as ChatHeader } from './components/ChatHeader.svelte';
|
|
18
|
+
export { default as AssistantMessage } from './components/AssistantMessage.svelte';
|
|
19
|
+
export { default as UserMessage } from './components/UserMessage.svelte';
|
|
20
|
+
export { default as MessageActions } from './components/MessageActions.svelte';
|
|
21
|
+
export { default as ArtifactChip } from './components/ArtifactChip.svelte';
|
|
22
|
+
export { default as MessageFiles } from './components/MessageFiles.svelte';
|
|
23
|
+
export { default as ProposalCard } from './components/ProposalCard.svelte';
|
package/dist/markdown.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/lib/markdown.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/lib/markdown.ts"],"names":[],"mappings":"AAkEA,MAAM,WAAW,eAAe;IAC9B,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAQD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO9C;AAMD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,WAAsB,GAAG,OAAO,CAenF;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,qBAAe,GAAG,OAAO,CAOjF;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAiEjF"}
|
package/dist/markdown.js
CHANGED
|
@@ -22,6 +22,7 @@ function ensureHook() {
|
|
|
22
22
|
const DOMPURIFY_CONFIG = {
|
|
23
23
|
ALLOWED_TAGS: [
|
|
24
24
|
'p',
|
|
25
|
+
'div',
|
|
25
26
|
'strong',
|
|
26
27
|
'em',
|
|
27
28
|
'a',
|
|
@@ -62,6 +63,9 @@ const DOMPURIFY_CONFIG = {
|
|
|
62
63
|
],
|
|
63
64
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
|
64
65
|
};
|
|
66
|
+
// Sentinels for the scrolling wrapper around tables (see parseMarkdown).
|
|
67
|
+
const TABLE_OPEN = '\u0000TO\u0000';
|
|
68
|
+
const TABLE_CLOSE = '\u0000TC\u0000';
|
|
65
69
|
const DEFAULT_IMAGE_HOSTS = ['classichomes.com', '.classichomes.com'];
|
|
66
70
|
export function escapeHtml(text) {
|
|
67
71
|
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -130,6 +134,10 @@ export function parseMarkdown(text, options = {}) {
|
|
|
130
134
|
html = parseTables(html);
|
|
131
135
|
html = parseLists(html);
|
|
132
136
|
html = escapePreservingTags(html, [CODE, INLINE]);
|
|
137
|
+
// Table wrappers go in after escaping, so a literal `<div>` in the text is never trusted.
|
|
138
|
+
html = html
|
|
139
|
+
.replaceAll(TABLE_OPEN, '<div class="agent-table-wrap">')
|
|
140
|
+
.replaceAll(TABLE_CLOSE, '</div>');
|
|
133
141
|
html = html.replace(/^[\t ]*(?:---+|\*\*\*+|___+)[\t ]*$/gm, '<hr>');
|
|
134
142
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
135
143
|
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
|
@@ -153,12 +161,12 @@ export function parseMarkdown(text, options = {}) {
|
|
|
153
161
|
return label;
|
|
154
162
|
if (isInternalUrl(raw, options.internalOrigin))
|
|
155
163
|
return `<a href="${url}" class="agent-internal-link" data-internal="true">${label}</a>`;
|
|
156
|
-
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
164
|
+
return `<a href="${url}" class="agent-external-link" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
157
165
|
});
|
|
158
166
|
codeBlocks.forEach((block, i) => (html = html.replace(`${CODE}${i}${CODE}`, block)));
|
|
159
167
|
inlineCodes.forEach((code, i) => (html = html.replace(`${INLINE}${i}${INLINE}`, code)));
|
|
160
168
|
html = html.replace(/\n\n+/g, '</p><p>');
|
|
161
|
-
html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/h[1-4]>|<\/table>|<\/tr>|<\/td>|<\/th>|<\/blockquote>|<hr>)\n(?!<)/g, '<br>');
|
|
169
|
+
html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/h[1-4]>|<\/table>|<\/div>|<\/tr>|<\/td>|<\/th>|<\/blockquote>|<hr>)\n(?!<)/g, '<br>');
|
|
162
170
|
if (!html.startsWith('<'))
|
|
163
171
|
html = `<p>${html}</p>`;
|
|
164
172
|
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
@@ -312,9 +320,9 @@ function parseTables(html) {
|
|
|
312
320
|
rows.push(cells(lines[j]));
|
|
313
321
|
j += 1;
|
|
314
322
|
}
|
|
315
|
-
out.push(
|
|
323
|
+
out.push(`${TABLE_OPEN}<table class="agent-table"><thead><tr>${header.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows
|
|
316
324
|
.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`)
|
|
317
|
-
.join('')}</tbody></table
|
|
325
|
+
.join('')}</tbody></table>${TABLE_CLOSE}`);
|
|
318
326
|
i = j;
|
|
319
327
|
continue;
|
|
320
328
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting helpers for the message components: sizes, times, day separators, icons for
|
|
3
|
+
* artifacts and files, and read-only descriptions of proposed document edits.
|
|
4
|
+
*/
|
|
5
|
+
import type { ArtifactMediaType, ProposedOperation } from '@classic-homes/agent-contracts';
|
|
6
|
+
import type { IconName } from '@classic-homes/theme-svelte';
|
|
7
|
+
import type { ChatMessage } from './reducer.js';
|
|
8
|
+
export declare function formatBytes(n: number): string;
|
|
9
|
+
/** "3:42 PM" in the viewer's locale. */
|
|
10
|
+
export declare function formatTime(ts: number, locale?: string): string;
|
|
11
|
+
/** Full date and time, for tooltips. */
|
|
12
|
+
export declare function formatDateTime(ts: number, locale?: string): string;
|
|
13
|
+
/** "Today", "Yesterday", or a short date ("Mon, Nov 3"; the year only when it differs). */
|
|
14
|
+
export declare function formatDay(ts: number, now?: number, locale?: string): string;
|
|
15
|
+
/** Indexes of messages that start a new calendar day (the first message always does). */
|
|
16
|
+
export declare function daySeparators(messages: Pick<ChatMessage, 'createdAt'>[]): Set<number>;
|
|
17
|
+
export declare function artifactIcon(mediaType: ArtifactMediaType): IconName;
|
|
18
|
+
export declare function artifactKind(mediaType: ArtifactMediaType): string;
|
|
19
|
+
export declare function fileIcon(mimeType: string): IconName;
|
|
20
|
+
export interface ProposalDescription {
|
|
21
|
+
/** What kind of edit, e.g. "Write values". */
|
|
22
|
+
action: string;
|
|
23
|
+
/** Which host applies it. */
|
|
24
|
+
host: 'Excel' | 'Word';
|
|
25
|
+
/** Where, when the operation names a readable target. */
|
|
26
|
+
target?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Read-only description of a proposed document edit; applying happens in the Office add-in. */
|
|
29
|
+
export declare function describeProposal(op: ProposedOperation): ProposalDescription;
|
|
30
|
+
//# sourceMappingURL=message-format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-format.d.ts","sourceRoot":"","sources":["../src/lib/message-format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAI7C;AAED,wCAAwC;AACxC,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wCAAwC;AACxC,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CASlE;AAQD,2FAA2F;AAC3F,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CASrF;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,QAAQ,CAcnE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,MAAM,CAejE;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAQnD;AAED,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAAG,mBAAmB,CAgB3E"}
|