@classic-homes/agent-chat 0.2.1 → 0.4.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 +18 -2
- package/dist/components/AgentChat.svelte +10 -0
- package/dist/components/AgentChat.svelte.d.ts +5 -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 +186 -0
- package/dist/components/AssistantMessage.svelte.d.ts +26 -0
- package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
- package/dist/components/Composer.svelte +221 -30
- package/dist/components/Composer.svelte.d.ts +8 -0
- package/dist/components/Composer.svelte.d.ts.map +1 -1
- package/dist/components/Markdown.svelte +87 -5
- package/dist/components/Markdown.svelte.d.ts +9 -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 +12 -144
- package/dist/components/MessageItem.svelte.d.ts +4 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +102 -30
- package/dist/components/MessageList.svelte.d.ts +2 -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/ToolTimeline.svelte +138 -54
- package/dist/components/ToolTimeline.svelte.d.ts +11 -3
- package/dist/components/ToolTimeline.svelte.d.ts.map +1 -1
- 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/composer.svelte.test.d.ts +2 -0
- package/dist/components/composer.svelte.test.d.ts.map +1 -0
- package/dist/components/composer.svelte.test.js +162 -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/composer-keys.d.ts +3 -0
- package/dist/composer-keys.d.ts.map +1 -1
- package/dist/composer-keys.js +18 -0
- package/dist/composer-prompts.d.ts +16 -0
- package/dist/composer-prompts.d.ts.map +1 -0
- package/dist/composer-prompts.js +27 -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 +58 -0
- package/dist/message-format.d.ts.map +1 -0
- package/dist/message-format.js +149 -0
- 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
|
-
|
|
6
|
-
tools
|
|
7
|
-
delegations
|
|
8
|
-
streaming
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
{#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
2
|
+
interface Props {
|
|
3
3
|
tools: ToolCallState[];
|
|
4
4
|
delegations: DelegationState[];
|
|
5
5
|
streaming?: boolean;
|
|
6
|
-
|
|
7
|
-
|
|
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;
|
|
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,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":"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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.svelte.test.d.ts","sourceRoot":"","sources":["../../src/lib/components/messages.svelte.test.ts"],"names":[],"mappings":""}
|