@classic-homes/agent-chat 0.3.0 → 0.4.1

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 (43) hide show
  1. package/README.md +5 -1
  2. package/dist/artifact-pages.d.ts +18 -0
  3. package/dist/artifact-pages.d.ts.map +1 -0
  4. package/dist/artifact-pages.js +89 -0
  5. package/dist/artifact-pages.test.d.ts +2 -0
  6. package/dist/artifact-pages.test.d.ts.map +1 -0
  7. package/dist/artifact-pages.test.js +102 -0
  8. package/dist/components/AgentChat.svelte +9 -0
  9. package/dist/components/AgentChat.svelte.d.ts +5 -0
  10. package/dist/components/AgentChat.svelte.d.ts.map +1 -1
  11. package/dist/components/ArtifactPanel.svelte +3 -6
  12. package/dist/components/ArtifactPanel.svelte.d.ts.map +1 -1
  13. package/dist/components/AssistantMessage.svelte +8 -1
  14. package/dist/components/AssistantMessage.svelte.d.ts +1 -0
  15. package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
  16. package/dist/components/Composer.svelte +221 -30
  17. package/dist/components/Composer.svelte.d.ts +8 -0
  18. package/dist/components/Composer.svelte.d.ts.map +1 -1
  19. package/dist/components/MessageItem.svelte +1 -0
  20. package/dist/components/MessageItem.svelte.d.ts +1 -0
  21. package/dist/components/MessageItem.svelte.d.ts.map +1 -1
  22. package/dist/components/MessageList.svelte +3 -0
  23. package/dist/components/MessageList.svelte.d.ts +1 -0
  24. package/dist/components/MessageList.svelte.d.ts.map +1 -1
  25. package/dist/components/ToolTimeline.svelte +138 -54
  26. package/dist/components/ToolTimeline.svelte.d.ts +11 -3
  27. package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
  28. package/dist/components/composer.svelte.test.d.ts +2 -0
  29. package/dist/components/composer.svelte.test.d.ts.map +1 -0
  30. package/dist/components/composer.svelte.test.js +162 -0
  31. package/dist/composer-keys.d.ts +3 -0
  32. package/dist/composer-keys.d.ts.map +1 -1
  33. package/dist/composer-keys.js +18 -0
  34. package/dist/composer-prompts.d.ts +16 -0
  35. package/dist/composer-prompts.d.ts.map +1 -0
  36. package/dist/composer-prompts.js +27 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/message-format.d.ts +28 -0
  41. package/dist/message-format.d.ts.map +1 -1
  42. package/dist/message-format.js +31 -0
  43. package/package.json +3 -3
@@ -1,62 +1,146 @@
1
+ <!--
2
+ @component
3
+ What the agent did in a turn: tool calls (status icon, readable name, duration) and the
4
+ specialists it delegated to, with their own calls nested. Open while the turn runs and folded
5
+ away when it finishes, unless the reader toggled it.
6
+ -->
1
7
  <script lang="ts">
2
- import { cn, Icon } from '@classic-homes/theme-svelte';
8
+ import { cn, Collapsible, Icon, Spinner, type IconName } from '@classic-homes/theme-svelte';
3
9
  import type { DelegationState, ToolCallState } from '../reducer.js';
10
+ import { formatDuration, groupToolRuns, humanizeToolName } from '../message-format.js';
4
11
 
5
- let {
6
- tools,
7
- delegations,
8
- streaming = false,
9
- }: { tools: ToolCallState[]; delegations: DelegationState[]; streaming?: boolean } = $props();
10
- let expanded = $state(false);
11
- const running = $derived(tools.filter((t) => !t.status).length);
12
- const label = $derived.by(() => {
13
- if (streaming && running > 0)
14
- return `Working: ${tools
15
- .filter((t) => !t.status)
16
- .map((t) => t.name)
17
- .join(', ')}`;
18
- const children = delegations.map((d) => d.child);
19
- const parts = [tools.length ? `${tools.length} tool call${tools.length === 1 ? '' : 's'}` : ''];
20
- if (children.length) parts.push(`via ${[...new Set(children)].join(', ')}`);
21
- return parts.filter(Boolean).join(' ');
12
+ interface Props {
13
+ tools: ToolCallState[];
14
+ delegations: DelegationState[];
15
+ streaming?: boolean;
16
+ /** Readable names by tool name (with or without the `server/` prefix). */
17
+ toolLabels?: Record<string, string>;
18
+ class?: string;
19
+ }
20
+ let { tools, delegations, streaming = false, toolLabels, class: className }: Props = $props();
21
+
22
+ /** The reader's choice; until they make one the timeline follows the turn. */
23
+ let toggled = $state<boolean | null>(null);
24
+ const open = $derived(toggled ?? streaming);
25
+ let details = $state<Record<string, boolean>>({});
26
+
27
+ const running = $derived(tools.filter((t) => !t.status));
28
+ const failed = $derived(
29
+ tools.filter((t) => t.status === 'error' || t.status === 'unauthenticated').length,
30
+ );
31
+ const refused = $derived(tools.filter((t) => t.status === 'refused').length);
32
+ const groups = $derived(groupToolRuns(tools, delegations));
33
+
34
+ function labelFor(tool: ToolCallState): string {
35
+ const full = tool.server ? `${tool.server}/${tool.name}` : tool.name;
36
+ return toolLabels?.[full] ?? toolLabels?.[tool.name] ?? humanizeToolName(tool.name);
37
+ }
38
+ function statusIcon(tool: ToolCallState): { icon: IconName; tone: string; label: string } {
39
+ switch (tool.status) {
40
+ case 'ok':
41
+ return { icon: 'check-circle', tone: 'text-success', label: 'Done' };
42
+ case 'error':
43
+ return { icon: 'x-circle', tone: 'text-destructive', label: 'Failed' };
44
+ case 'refused':
45
+ return { icon: 'alert-triangle', tone: 'text-warning', label: 'Refused' };
46
+ case 'unauthenticated':
47
+ return { icon: 'lock', tone: 'text-warning', label: 'Needs sign-in' };
48
+ default:
49
+ return { icon: 'circle', tone: 'text-muted-foreground', label: 'Interrupted' };
50
+ }
51
+ }
52
+
53
+ const summary = $derived.by(() => {
54
+ if (streaming && running.length) return `${labelFor(running[running.length - 1]!)}…`;
55
+ const parts: string[] = [];
56
+ if (tools.length) parts.push(`${tools.length} tool call${tools.length === 1 ? '' : 's'}`);
57
+ const children = [...new Set(delegations.map((d) => d.child))];
58
+ if (children.length) parts.push(`via ${children.join(', ')}`);
59
+ if (failed) parts.push(`${failed} failed`);
60
+ if (refused) parts.push(`${refused} refused`);
61
+ return parts.join(' · ') || 'Working…';
22
62
  });
23
63
  </script>
24
64
 
25
- {#if tools.length > 0 || delegations.length > 0}
26
- <div class="mb-2 mt-1 text-xs text-muted-foreground">
27
- <button
28
- type="button"
29
- class="inline-flex items-center gap-1.5 rounded-sm py-0.5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
30
- onclick={() => (expanded = !expanded)}
31
- aria-expanded={expanded}
32
- >
33
- <Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={14} />
34
- <span>{label}</span>
35
- {#if streaming && running > 0}<span
36
- class="size-2 rounded-full bg-primary motion-safe:animate-pulse"
37
- aria-hidden="true"
38
- ></span>{/if}
39
- </button>
40
- {#if expanded}
41
- <ol class="mt-1.5 grid gap-1 pl-4">
42
- {#each tools as tool (tool.callId)}
43
- <li class="grid grid-cols-[auto_1fr_auto] gap-x-2">
44
- <span class="font-mono">{tool.server ? `${tool.server}/` : ''}{tool.name}</span>
45
- <span>{tool.agent}</span>
46
- <span
47
- class={cn(
48
- (tool.status === 'error' || tool.status === 'refused') && 'text-destructive',
49
- )}
50
- >{tool.status ?? 'running'}{tool.durationMs !== undefined
51
- ? ` · ${tool.durationMs} ms`
52
- : ''}</span
53
- >
54
- {#if tool.summary}<div class="col-span-full whitespace-pre-wrap opacity-80">
55
- {tool.summary}
56
- </div>{/if}
57
- </li>
58
- {/each}
59
- </ol>
65
+ {#snippet toolRow(tool: ToolCallState)}
66
+ {@const s = statusIcon(tool)}
67
+ {@const preview = [tool.argsPreview, tool.summary].filter(Boolean).join('\n→ ')}
68
+ <li class="min-w-0">
69
+ <div class="flex items-center gap-2">
70
+ {#if !tool.status && streaming}
71
+ <Spinner size="sm" label="Running" class="shrink-0" />
72
+ {:else}
73
+ <span class={cn('shrink-0', s.tone)} title={s.label}>
74
+ <Icon name={s.icon} size={14} />
75
+ <span class="sr-only">{s.label}</span>
76
+ </span>
77
+ {/if}
78
+ <span class="min-w-0 truncate text-foreground">{labelFor(tool)}</span>
79
+ {#if tool.server}<span class="hidden shrink-0 font-mono sm:inline">{tool.server}</span>{/if}
80
+ {#if tool.durationMs !== undefined}
81
+ <span class="ml-auto shrink-0 tabular-nums">{formatDuration(tool.durationMs)}</span>
82
+ {/if}
83
+ </div>
84
+ {#if preview}
85
+ <button
86
+ type="button"
87
+ class={cn(
88
+ 'ml-[22px] mt-0.5 block max-w-full whitespace-pre-wrap rounded text-left font-mono text-[11px] leading-snug [overflow-wrap:anywhere] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
89
+ !details[tool.callId] && 'line-clamp-2',
90
+ )}
91
+ aria-expanded={!!details[tool.callId]}
92
+ aria-label={details[tool.callId] ? 'Show less' : 'Show the full call'}
93
+ onclick={() => (details[tool.callId] = !details[tool.callId])}>{preview}</button
94
+ >
60
95
  {/if}
61
- </div>
96
+ </li>
97
+ {/snippet}
98
+
99
+ {#if tools.length > 0 || delegations.length > 0}
100
+ <Collapsible
101
+ {open}
102
+ onOpenChange={(next) => (toggled = next)}
103
+ class={cn('text-xs text-muted-foreground', className)}
104
+ triggerClass="gap-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
105
+ contentClass="pt-1.5"
106
+ >
107
+ {#snippet trigger()}
108
+ <span class="inline-flex items-center gap-1.5">
109
+ {#if streaming && running.length}
110
+ <Spinner size="sm" label="Working" />
111
+ {:else}
112
+ <Icon name="tool" size={12} />
113
+ {/if}
114
+ <span>{summary}</span>
115
+ </span>
116
+ {/snippet}
117
+ <div class="grid gap-2 border-l border-border pl-3">
118
+ {#each groups as group, gi (group.delegation?.childRunId ?? `top-${gi}`)}
119
+ {#if group.delegation}
120
+ <div class="grid gap-1">
121
+ <div class="flex items-center gap-2 text-foreground">
122
+ {#if group.delegation.status === 'start' && streaming}
123
+ <Spinner size="sm" label="Working" class="shrink-0" />
124
+ {:else}
125
+ <Icon name="corner-down-right" size={14} class="shrink-0 text-muted-foreground" />
126
+ {/if}
127
+ <span>Delegated to <span class="font-medium">{group.delegation.child}</span></span>
128
+ </div>
129
+ {#if group.delegation.summary}
130
+ <p class="ml-[22px] [overflow-wrap:anywhere]">{group.delegation.summary}</p>
131
+ {/if}
132
+ {#if group.tools.length}
133
+ <ol class="ml-[22px] grid gap-1">
134
+ {#each group.tools as tool (tool.callId)}{@render toolRow(tool)}{/each}
135
+ </ol>
136
+ {/if}
137
+ </div>
138
+ {:else}
139
+ <ol class="grid gap-1">
140
+ {#each group.tools as tool (tool.callId)}{@render toolRow(tool)}{/each}
141
+ </ol>
142
+ {/if}
143
+ {/each}
144
+ </div>
145
+ </Collapsible>
62
146
  {/if}
@@ -1,10 +1,18 @@
1
1
  import type { DelegationState, ToolCallState } from '../reducer.js';
2
- type $$ComponentProps = {
2
+ interface Props {
3
3
  tools: ToolCallState[];
4
4
  delegations: DelegationState[];
5
5
  streaming?: boolean;
6
- };
7
- declare const ToolTimeline: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ /** Readable names by tool name (with or without the `server/` prefix). */
7
+ toolLabels?: Record<string, string>;
8
+ class?: string;
9
+ }
10
+ /**
11
+ * What the agent did in a turn: tool calls (status icon, readable name, duration) and the
12
+ * specialists it delegated to, with their own calls nested. Open while the turn runs and folded
13
+ * away when it finishes, unless the reader toggled it.
14
+ */
15
+ declare const ToolTimeline: import("svelte").Component<Props, {}, "">;
8
16
  type ToolTimeline = ReturnType<typeof ToolTimeline>;
9
17
  export default ToolTimeline;
10
18
  //# sourceMappingURL=ToolTimeline.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ToolTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ToolTimeline.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnE,KAAK,gBAAgB,GAAI;IAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IAAC,WAAW,EAAE,eAAe,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAuD1G,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ToolTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ToolTimeline.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAIlE,UAAU,KAAK;IACb,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6HH;;;;GAIG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=composer.svelte.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composer.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/composer.svelte.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,162 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { LIMITS } from '@classic-homes/agent-contracts';
5
+ import { promptMenuKeyAction } from '../composer-keys.js';
6
+ import { filterPrompts, promptQuery } from '../composer-prompts.js';
7
+ import { formatDuration, groupToolRuns, humanizeToolName } from '../message-format.js';
8
+ import Composer from './Composer.svelte';
9
+ import ToolTimeline from './ToolTimeline.svelte';
10
+ afterEach(() => cleanup());
11
+ const prompts = [
12
+ {
13
+ id: '1',
14
+ label: 'Weekly recap',
15
+ description: 'Summary for a community',
16
+ text: 'Recap {{community}}',
17
+ },
18
+ { id: '2', label: 'Lot report', text: async () => 'Report on lot {{lot}}' },
19
+ { id: '3', label: 'Draft email', description: 'Weekly buyer update', text: 'Email' },
20
+ ];
21
+ describe('prompt menu helpers', () => {
22
+ it('reads the query only from a bare /command', () => {
23
+ expect(promptQuery('/')).toBe('');
24
+ expect(promptQuery('/wee')).toBe('wee');
25
+ expect(promptQuery('/weekly recap')).toBeNull();
26
+ expect(promptQuery('hello /x')).toBeNull();
27
+ });
28
+ it('ranks label matches over description matches', () => {
29
+ expect(filterPrompts(prompts, 'weekly').map((p) => p.id)).toEqual(['1', '3']);
30
+ expect(filterPrompts(prompts, 'lot').map((p) => p.id)).toEqual(['2']);
31
+ expect(filterPrompts(prompts, '')).toHaveLength(3);
32
+ });
33
+ it('maps keys while the menu is open', () => {
34
+ expect(promptMenuKeyAction({ key: 'ArrowDown' })).toBe('next');
35
+ expect(promptMenuKeyAction({ key: 'Enter' })).toBe('choose');
36
+ expect(promptMenuKeyAction({ key: 'Enter', shiftKey: true })).toBeNull();
37
+ expect(promptMenuKeyAction({ key: 'Enter', isComposing: true })).toBeNull();
38
+ expect(promptMenuKeyAction({ key: 'Escape' })).toBe('close');
39
+ });
40
+ });
41
+ describe('tool helpers', () => {
42
+ it('humanizes names and durations', () => {
43
+ expect(humanizeToolName('hotsheet/list_plans')).toBe('List plans');
44
+ expect(humanizeToolName('getLotDetails')).toBe('Get lot details');
45
+ expect(formatDuration(640)).toBe('640 ms');
46
+ expect(formatDuration(1234)).toBe('1.2 s');
47
+ expect(formatDuration(75_000)).toBe('1 min 15 s');
48
+ });
49
+ it('groups calls under their delegation', () => {
50
+ const t = (callId, runId) => ({ callId, runId });
51
+ const groups = groupToolRuns([t('a', 'root'), t('b', 'child-1'), t('c', 'root')], [{ child: 'research', childRunId: 'child-1', status: 'end' }]);
52
+ expect(groups.map((g) => [g.delegation?.child, g.tools.map((x) => x.callId)])).toEqual([
53
+ [undefined, ['a', 'c']],
54
+ ['research', ['b']],
55
+ ]);
56
+ });
57
+ });
58
+ describe('Composer', () => {
59
+ it('inserts a saved prompt from the / menu with the keyboard', async () => {
60
+ const user = userEvent.setup();
61
+ render(Composer, { props: { prompts } });
62
+ const box = screen.getByRole('combobox', { name: 'Message' });
63
+ await user.type(box, '/lot');
64
+ const listbox = await screen.findByRole('listbox', { name: 'Saved prompts' });
65
+ expect(listbox.textContent).toContain('Lot report');
66
+ await user.keyboard('{Enter}');
67
+ await waitFor(() => expect(box.value).toBe('Report on lot {{lot}}'));
68
+ expect(screen.queryByRole('listbox')).toBeNull();
69
+ });
70
+ it('closes the menu on Escape and keeps the draft', async () => {
71
+ const user = userEvent.setup();
72
+ render(Composer, { props: { prompts } });
73
+ const box = screen.getByRole('combobox', { name: 'Message' });
74
+ await user.type(box, '/');
75
+ await screen.findByRole('listbox');
76
+ await user.keyboard('{ArrowDown}{Escape}');
77
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
78
+ expect(box.value).toBe('/');
79
+ });
80
+ it('uploads pasted and dropped files and shows them while uploading', async () => {
81
+ let finish = () => { };
82
+ const upload = vi.fn((file) => new Promise((resolve) => {
83
+ finish = resolve;
84
+ void file;
85
+ }));
86
+ render(Composer, { props: { upload } });
87
+ const box = screen.getByRole('textbox', { name: 'Message' });
88
+ const file = new File(['x'], 'shot.png', { type: 'image/png' });
89
+ await fireEvent.paste(box, { clipboardData: { files: [file], types: ['Files'] } });
90
+ expect(upload).toHaveBeenCalledWith(file);
91
+ expect(await screen.findByText('shot.png')).toBeTruthy();
92
+ expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(true);
93
+ finish({ fileId: 'f1', name: 'shot.png', mimeType: 'image/png', bytes: 1, status: 'uploaded' });
94
+ expect(await screen.findByRole('button', { name: 'Remove shot.png' })).toBeTruthy();
95
+ const card = screen.getByRole('group', { name: 'Message composer' });
96
+ const dropped = new File(['y'], 'plan.pdf', { type: 'application/pdf' });
97
+ const dataTransfer = { types: ['Files'], files: [dropped], dropEffect: 'none' };
98
+ await fireEvent.dragEnter(card, { dataTransfer });
99
+ expect(screen.getByText('Drop files to attach')).toBeTruthy();
100
+ await fireEvent.drop(card, { dataTransfer });
101
+ expect(upload).toHaveBeenLastCalledWith(dropped);
102
+ expect(screen.queryByText('Drop files to attach')).toBeNull();
103
+ });
104
+ it('counts characters near the limit', async () => {
105
+ const { rerender } = render(Composer, { props: { value: 'short' } });
106
+ expect(screen.queryByText(/\/ 8,000/)).toBeNull();
107
+ await rerender({ value: 'x'.repeat(Math.ceil(LIMITS.messageChars * 0.85)) });
108
+ expect(screen.getByText(/\/ 8,000/)).toBeTruthy();
109
+ });
110
+ });
111
+ describe('ToolTimeline', () => {
112
+ const tool = (patch) => ({
113
+ callId: 'c1',
114
+ name: 'list_plans',
115
+ server: 'hotsheet',
116
+ argsPreview: '{"community":"Wolf Ranch"}',
117
+ agent: 'classic-agent',
118
+ runId: 'root',
119
+ ...patch,
120
+ });
121
+ it('is open with a spinner while running and folds when the turn ends', async () => {
122
+ const { rerender } = render(ToolTimeline, {
123
+ props: { tools: [tool({})], delegations: [], streaming: true },
124
+ });
125
+ expect(screen.getAllByText('List plans…').length).toBeGreaterThan(0);
126
+ expect(screen.getByText('{"community":"Wolf Ranch"}')).toBeTruthy();
127
+ await rerender({
128
+ tools: [tool({ status: 'ok', durationMs: 1234 })],
129
+ delegations: [],
130
+ streaming: false,
131
+ });
132
+ const trigger = screen.getByRole('button', { name: /1 tool call/ });
133
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
134
+ await userEvent.click(trigger);
135
+ expect(screen.getByText('1.2 s')).toBeTruthy();
136
+ });
137
+ it('uses host labels, nests delegations and counts failures', async () => {
138
+ render(ToolTimeline, {
139
+ props: {
140
+ streaming: false,
141
+ toolLabels: { 'hotsheet/list_plans': 'Look up plans' },
142
+ tools: [
143
+ tool({ status: 'ok' }),
144
+ tool({
145
+ callId: 'c2',
146
+ name: 'web_search',
147
+ server: undefined,
148
+ runId: 'child',
149
+ status: 'error',
150
+ }),
151
+ ],
152
+ delegations: [{ child: 'research', childRunId: 'child', status: 'end' }],
153
+ },
154
+ });
155
+ const trigger = screen.getByRole('button', { name: /2 tool calls · via research · 1 failed/ });
156
+ await userEvent.click(trigger);
157
+ expect(screen.getByText('Look up plans')).toBeTruthy();
158
+ expect(screen.getByText('research')).toBeTruthy();
159
+ expect(screen.getByText('Web search')).toBeTruthy();
160
+ expect(screen.getByText('Failed')).toBeTruthy();
161
+ });
162
+ });
@@ -1,5 +1,7 @@
1
1
  /** What a keydown in the composer textarea should do. Pure so it can be tested without a DOM. */
2
2
  export type ComposerKeyAction = 'send' | 'cancel' | null;
3
+ /** What a keydown does while the `/` prompt menu is open. */
4
+ export type PromptMenuKeyAction = 'next' | 'previous' | 'choose' | 'close' | null;
3
5
  export interface ComposerKey {
4
6
  key: string;
5
7
  shiftKey?: boolean;
@@ -8,4 +10,5 @@ export interface ComposerKey {
8
10
  keyCode?: number;
9
11
  }
10
12
  export declare function composerKeyAction(event: ComposerKey, streaming: boolean): ComposerKeyAction;
13
+ export declare function promptMenuKeyAction(event: ComposerKey): PromptMenuKeyAction;
11
14
  //# sourceMappingURL=composer-keys.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer-keys.d.ts","sourceRoot":"","sources":["../src/lib/composer-keys.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;AAEzD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAM3F"}
1
+ {"version":3,"file":"composer-keys.d.ts","sourceRoot":"","sources":["../src/lib/composer-keys.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;AAEzD,6DAA6D;AAC7D,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,IAAI,CAAC;AAElF,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAM3F;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,mBAAmB,CAgB3E"}
@@ -8,3 +8,21 @@ export function composerKeyAction(event, streaming) {
8
8
  return 'send';
9
9
  return null;
10
10
  }
11
+ export function promptMenuKeyAction(event) {
12
+ if (event.isComposing || event.keyCode === 229)
13
+ return null;
14
+ switch (event.key) {
15
+ case 'ArrowDown':
16
+ return 'next';
17
+ case 'ArrowUp':
18
+ return 'previous';
19
+ case 'Enter':
20
+ return event.shiftKey ? null : 'choose';
21
+ case 'Tab':
22
+ return 'choose';
23
+ case 'Escape':
24
+ return 'close';
25
+ default:
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The composer's `/` prompt menu: which query the draft asks for and which prompts match. Pure,
3
+ * so the matching can be tested without a DOM.
4
+ */
5
+ export interface ComposerPrompt {
6
+ id: string;
7
+ label: string;
8
+ description?: string;
9
+ /** The text to insert, or a loader for it (e.g. fetch the saved template). */
10
+ text: string | (() => Promise<string>);
11
+ }
12
+ /** The filter text when the draft is a bare `/command` (no spaces or newlines yet), else null. */
13
+ export declare function promptQuery(draft: string): string | null;
14
+ /** Prompts whose label or description contains every word of the query, labels first. */
15
+ export declare function filterPrompts<T extends Pick<ComposerPrompt, 'label' | 'description'>>(prompts: T[], query: string, limit?: number): T[];
16
+ //# sourceMappingURL=composer-prompts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composer-prompts.d.ts","sourceRoot":"","sources":["../src/lib/composer-prompts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,IAAI,EAAE,MAAM,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;CACxC;AAED,kGAAkG;AAClG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGxD;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,CAAC,SAAS,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,aAAa,CAAC,EACnF,OAAO,EAAE,CAAC,EAAE,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,SAAI,GACR,CAAC,EAAE,CAkBL"}
@@ -0,0 +1,27 @@
1
+ /** The filter text when the draft is a bare `/command` (no spaces or newlines yet), else null. */
2
+ export function promptQuery(draft) {
3
+ const match = /^\/(\S*)$/.exec(draft);
4
+ return match ? match[1] : null;
5
+ }
6
+ /** Prompts whose label or description contains every word of the query, labels first. */
7
+ export function filterPrompts(prompts, query, limit = 8) {
8
+ const words = query
9
+ .toLowerCase()
10
+ .split(/[-_\s]+/)
11
+ .filter(Boolean);
12
+ const score = (p) => {
13
+ const label = p.label.toLowerCase();
14
+ const haystack = `${label} ${p.description?.toLowerCase() ?? ''}`;
15
+ if (!words.every((w) => haystack.includes(w)))
16
+ return -1;
17
+ if (!words.length)
18
+ return 1;
19
+ return words.every((w) => label.includes(w)) ? (label.startsWith(words[0]) ? 3 : 2) : 1;
20
+ };
21
+ return prompts
22
+ .map((p, i) => ({ p, i, s: score(p) }))
23
+ .filter((x) => x.s >= 0)
24
+ .sort((a, b) => b.s - a.s || a.i - b.i)
25
+ .slice(0, limit)
26
+ .map((x) => x.p);
27
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './body-lock.js';
2
2
  export * from './composer-keys.js';
3
+ export * from './composer-prompts.js';
3
4
  export * from './markdown.js';
4
5
  export * from './message-format.js';
5
6
  export * from './reducer.js';
@@ -1 +1 @@
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"}
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,uBAAuB,CAAC;AACtC,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,6 @@
1
1
  export * from './body-lock.js';
2
2
  export * from './composer-keys.js';
3
+ export * from './composer-prompts.js';
3
4
  export * from './markdown.js';
4
5
  export * from './message-format.js';
5
6
  export * from './reducer.js';
@@ -27,4 +27,32 @@ export interface ProposalDescription {
27
27
  }
28
28
  /** Read-only description of a proposed document edit; applying happens in the Office add-in. */
29
29
  export declare function describeProposal(op: ProposedOperation): ProposalDescription;
30
+ /** "list_plans" / "listPlans" / "hotsheet/list-plans" → "List plans". */
31
+ export declare function humanizeToolName(name: string): string;
32
+ /** 640 → "640 ms", 1234 → "1.2 s", 75000 → "1 min 15 s". */
33
+ export declare function formatDuration(ms: number): string;
34
+ export interface ToolRunGroup<T extends {
35
+ runId: string;
36
+ }> {
37
+ /** The delegation this group ran under; undefined for the top-level agent. */
38
+ delegation?: {
39
+ child: string;
40
+ childRunId: string;
41
+ status: 'start' | 'end';
42
+ summary?: string;
43
+ };
44
+ tools: T[];
45
+ }
46
+ /**
47
+ * Group tool calls by the run that made them: the top-level agent's own calls first, then one
48
+ * group per delegation (in the order the delegations started) with the child's calls.
49
+ */
50
+ export declare function groupToolRuns<T extends {
51
+ runId: string;
52
+ }>(tools: T[], delegations: {
53
+ child: string;
54
+ childRunId: string;
55
+ status: 'start' | 'end';
56
+ summary?: string;
57
+ }[]): ToolRunGroup<T>[];
30
58
  //# sourceMappingURL=message-format.d.ts.map
@@ -1 +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"}
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;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQrD;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAMjD;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE;IACvD,8EAA8E;IAC9E,UAAU,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,GAAG,KAAK,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9F,KAAK,EAAE,CAAC,EAAE,CAAC;CACZ;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EACvD,KAAK,EAAE,CAAC,EAAE,EACV,WAAW,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAC9F,YAAY,CAAC,CAAC,CAAC,EAAE,CAMnB"}
@@ -116,3 +116,34 @@ export function describeProposal(op) {
116
116
  return { action: 'Replace selection', host: 'Word' };
117
117
  }
118
118
  }
119
+ /** "list_plans" / "listPlans" / "hotsheet/list-plans" → "List plans". */
120
+ export function humanizeToolName(name) {
121
+ const bare = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
122
+ const words = bare
123
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
124
+ .replace(/[_\-.]+/g, ' ')
125
+ .trim()
126
+ .toLowerCase();
127
+ return words ? words[0].toUpperCase() + words.slice(1) : name;
128
+ }
129
+ /** 640 → "640 ms", 1234 → "1.2 s", 75000 → "1 min 15 s". */
130
+ export function formatDuration(ms) {
131
+ if (ms < 1000)
132
+ return `${Math.round(ms)} ms`;
133
+ if (ms < 60_000)
134
+ return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
135
+ const min = Math.floor(ms / 60_000);
136
+ const s = Math.round((ms % 60_000) / 1000);
137
+ return s ? `${min} min ${s} s` : `${min} min`;
138
+ }
139
+ /**
140
+ * Group tool calls by the run that made them: the top-level agent's own calls first, then one
141
+ * group per delegation (in the order the delegations started) with the child's calls.
142
+ */
143
+ export function groupToolRuns(tools, delegations) {
144
+ const childRuns = new Set(delegations.map((d) => d.childRunId));
145
+ const groups = [{ tools: tools.filter((t) => !childRuns.has(t.runId)) }];
146
+ for (const delegation of delegations)
147
+ groups.push({ delegation, tools: tools.filter((t) => t.runId === delegation.childRunId) });
148
+ return groups.filter((g) => g.delegation || g.tools.length);
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/agent-chat",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline, artifacts and MCP-UI sandbox frames. Consumed by the Classic App.",
@@ -17,8 +17,8 @@
17
17
  "dist"
18
18
  ],
19
19
  "dependencies": {
20
- "@classic-homes/agent-client": "0.3.0",
21
- "@classic-homes/agent-contracts": "0.3.0",
20
+ "@classic-homes/agent-client": "0.4.1",
21
+ "@classic-homes/agent-contracts": "0.4.1",
22
22
  "dompurify": "3.4.17"
23
23
  },
24
24
  "peerDependencies": {