@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
package/README.md
CHANGED
|
@@ -21,8 +21,9 @@ artifacts and MCP-UI sandbox frames. The Classic App's `/agent` page and agent p
|
|
|
21
21
|
};
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Without that entry the chat renders unstyled. Markdown prose (rendered from model text)
|
|
25
|
-
|
|
24
|
+
Without that entry the chat renders unstyled. Markdown prose (rendered from model text) uses
|
|
25
|
+
arbitrary-variant utilities on its container (`[&_ul]:list-disc`), not a stylesheet: the
|
|
26
|
+
Classic App's cascade-layers polyfill outranks plain component CSS.
|
|
26
27
|
|
|
27
28
|
## Usage
|
|
28
29
|
|
|
@@ -37,4 +38,15 @@ artifacts and MCP-UI sandbox frames. The Classic App's `/agent` page and agent p
|
|
|
37
38
|
`client` is an `AgentApiClient` from `@classic-homes/agent-client`. `sandboxOrigin` is the
|
|
38
39
|
agent-api origin that serves `/frame`; artifacts and MCP-UI resources only ever render there.
|
|
39
40
|
|
|
41
|
+
## Components
|
|
42
|
+
|
|
43
|
+
`AgentChat` composes the rest; each is also exported for other layouts:
|
|
44
|
+
|
|
45
|
+
- `ChatHeader`, `ConversationSidebar`: title and conversation actions; the history rail.
|
|
46
|
+
- `MessageList`: transcript with day separators and a jump-to-latest button.
|
|
47
|
+
- `MessageItem` → `UserMessage` / `AssistantMessage`, with `MessageActions` (copy, time),
|
|
48
|
+
`ArtifactChip`, `MessageFiles` (image lightbox, downloads) and `ProposalCard` (read-only
|
|
49
|
+
document edits; applying stays in the Office add-in).
|
|
50
|
+
- `Composer`, `ToolTimeline`, `ArtifactPanel`, `UiFrame`, `ModeBanner`.
|
|
51
|
+
|
|
40
52
|
Publishing: `docs/operations/package-publishing.md`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body-lock.d.ts","sourceRoot":"","sources":["../src/lib/body-lock.ts"],"names":[],"mappings":"AAYA,wBAAgB,oBAAoB,CAAC,QAAQ,SAAY,GAAG,IAAI,CAY/D"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for a bits-ui 1.x body-lock race (theme-svelte Sheet/Dialog): when one modal closes and
|
|
3
|
+
* another opens in the same tick, the closing one's deferred restore saves the opening one's
|
|
4
|
+
* locked style as the "original", so closing the second leaves `<body>` at
|
|
5
|
+
* `pointer-events: none; overflow: hidden` and the page stops taking clicks.
|
|
6
|
+
*
|
|
7
|
+
* Call after a sheet or dialog closes. Once the close animation is over, if no modal is open
|
|
8
|
+
* but the body is still locked, the lock is cleared. With a modal open nothing is touched.
|
|
9
|
+
*/
|
|
10
|
+
const SETTLE_MS = 400;
|
|
11
|
+
const OPEN_MODAL = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]';
|
|
12
|
+
export function releaseStuckBodyLock(settleMs = SETTLE_MS) {
|
|
13
|
+
if (typeof document === 'undefined')
|
|
14
|
+
return;
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
if (document.querySelector(OPEN_MODAL))
|
|
17
|
+
return;
|
|
18
|
+
const body = document.body;
|
|
19
|
+
if (body.style.pointerEvents !== 'none')
|
|
20
|
+
return;
|
|
21
|
+
body.style.removeProperty('pointer-events');
|
|
22
|
+
body.style.removeProperty('overflow');
|
|
23
|
+
body.style.removeProperty('padding-right');
|
|
24
|
+
body.style.removeProperty('margin-right');
|
|
25
|
+
body.style.removeProperty('--scrollbar-width');
|
|
26
|
+
}, settleMs);
|
|
27
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { BackendMode } from '@classic-homes/agent-contracts';
|
|
10
10
|
import { cn, ResizablePanes, Sheet } from '@classic-homes/theme-svelte';
|
|
11
11
|
import { MediaQuery } from 'svelte/reactivity';
|
|
12
|
+
import { releaseStuckBodyLock } from '../body-lock.js';
|
|
12
13
|
import { ChatSession } from '../session.svelte.js';
|
|
13
14
|
import ArtifactPanel from './ArtifactPanel.svelte';
|
|
14
15
|
import Composer from './Composer.svelte';
|
|
@@ -28,6 +29,12 @@
|
|
|
28
29
|
getContext?: () => DocumentContext | null | undefined;
|
|
29
30
|
onconversationstarted?: (conversationId: string) => void;
|
|
30
31
|
ontoolresult?: (name: string, status: string) => void;
|
|
32
|
+
/** A turn finished streaming (any outcome); refresh conversation lists here. */
|
|
33
|
+
onturnend?: (info: {
|
|
34
|
+
requestId: string;
|
|
35
|
+
conversationId: string | null;
|
|
36
|
+
status: string;
|
|
37
|
+
}) => void;
|
|
31
38
|
onnavigate?: (href: string) => void;
|
|
32
39
|
onnotify?: (message: string) => void;
|
|
33
40
|
/** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
|
|
@@ -50,6 +57,7 @@
|
|
|
50
57
|
getContext,
|
|
51
58
|
onconversationstarted,
|
|
52
59
|
ontoolresult,
|
|
60
|
+
onturnend,
|
|
53
61
|
onnavigate,
|
|
54
62
|
onnotify,
|
|
55
63
|
ontoolrequest,
|
|
@@ -69,6 +77,15 @@
|
|
|
69
77
|
getContext,
|
|
70
78
|
onConversationStarted: (id) => onconversationstarted?.(id),
|
|
71
79
|
onToolResult: (name, status) => ontoolresult?.(name, status),
|
|
80
|
+
onTurnEnd: (info) => onturnend?.(info),
|
|
81
|
+
// Open the pane when the runtime starts an artifact, not by watching state later: a fast
|
|
82
|
+
// stream can open and close it before any effect sees `open`.
|
|
83
|
+
onEvent: (event) => {
|
|
84
|
+
if (event.type === 'artifact' && event.op === 'open') {
|
|
85
|
+
selectedArtifact = event.artifactId;
|
|
86
|
+
artifactsOpen = true;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
72
89
|
});
|
|
73
90
|
// The constructor already knows `conversationId`; what decides a fetch is whether the
|
|
74
91
|
// transcript for it has been loaded (or started) in this session.
|
|
@@ -92,14 +109,6 @@
|
|
|
92
109
|
const artifacts = $derived(
|
|
93
110
|
session.messages.flatMap((m) => (m.role === 'assistant' ? m.artifacts : [])),
|
|
94
111
|
);
|
|
95
|
-
$effect(() => {
|
|
96
|
-
// A newly opened artifact pops the panel.
|
|
97
|
-
const last = artifacts[artifacts.length - 1];
|
|
98
|
-
if (last && last.open && selectedArtifact !== last.artifactId) {
|
|
99
|
-
selectedArtifact = last.artifactId;
|
|
100
|
-
artifactsOpen = true;
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
112
|
|
|
104
113
|
// The page shows artifacts beside the chat on wide screens; narrow screens and the panel use a
|
|
105
114
|
// sheet so the transcript keeps its width.
|
|
@@ -146,6 +155,7 @@
|
|
|
146
155
|
ontool={(name, params) => ontoolrequest?.(name, params)}
|
|
147
156
|
{onnotify}
|
|
148
157
|
onretry={retry}
|
|
158
|
+
oncontinue={() => void session.send('Continue.')}
|
|
149
159
|
{empty}
|
|
150
160
|
/>
|
|
151
161
|
<Composer
|
|
@@ -196,7 +206,10 @@
|
|
|
196
206
|
{@render chat()}
|
|
197
207
|
<Sheet
|
|
198
208
|
open={showArtifacts}
|
|
199
|
-
onOpenChange={(open) =>
|
|
209
|
+
onOpenChange={(open) => {
|
|
210
|
+
artifactsOpen = open;
|
|
211
|
+
if (!open) releaseStuckBodyLock();
|
|
212
|
+
}}
|
|
200
213
|
side="right"
|
|
201
214
|
size="lg"
|
|
202
215
|
title={selectedTitle}
|
|
@@ -14,6 +14,12 @@ interface Props {
|
|
|
14
14
|
getContext?: () => DocumentContext | null | undefined;
|
|
15
15
|
onconversationstarted?: (conversationId: string) => void;
|
|
16
16
|
ontoolresult?: (name: string, status: string) => void;
|
|
17
|
+
/** A turn finished streaming (any outcome); refresh conversation lists here. */
|
|
18
|
+
onturnend?: (info: {
|
|
19
|
+
requestId: string;
|
|
20
|
+
conversationId: string | null;
|
|
21
|
+
status: string;
|
|
22
|
+
}) => void;
|
|
17
23
|
onnavigate?: (href: string) => void;
|
|
18
24
|
onnotify?: (message: string) => void;
|
|
19
25
|
/** A UI resource asked to run a tool; the host confirms with the user and may send a message. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AgentChat.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AgentChat.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EACR,QAAQ,EACR,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"AgentChat.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AgentChat.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EACR,QAAQ,EACR,UAAU,EACV,eAAe,EAEhB,MAAM,gCAAgC,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAWhE,UAAU,KAAK;IACb,MAAM,EAAE,cAAc,CAAC;IACvB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,+EAA+E;IAC/E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,qBAAqB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,gFAAgF;IAChF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,IAAI,CAAC;IACX,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,iGAAiG;IACjG,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACnC,KAAK,CAAC,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6IH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A card-like button for one artifact in a message: type icon, title and kind, and a spinner while
|
|
4
|
+
the artifact is still being written. Opens it in the artifact pane.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { cn, Icon, Spinner } from '@classic-homes/theme-svelte';
|
|
8
|
+
import type { Artifact } from '../reducer.js';
|
|
9
|
+
import { artifactIcon, artifactKind } from '../message-format.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
artifact: Artifact;
|
|
13
|
+
onopen?: (artifactId: string) => void;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
let { artifact, onopen, class: className }: Props = $props();
|
|
17
|
+
const title = $derived(artifact.title ?? 'Untitled artifact');
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
class={cn(
|
|
23
|
+
'flex w-full max-w-sm items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 text-left shadow-sm transition hover:border-primary/40 hover:shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
aria-label={artifact.open ? `${title} (writing)` : `Open ${title}`}
|
|
27
|
+
onclick={() => onopen?.(artifact.artifactId)}
|
|
28
|
+
>
|
|
29
|
+
<span
|
|
30
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
|
31
|
+
>
|
|
32
|
+
<Icon name={artifactIcon(artifact.mediaType)} size={18} />
|
|
33
|
+
</span>
|
|
34
|
+
<span class="min-w-0 flex-1">
|
|
35
|
+
<span class="block truncate text-sm font-medium text-foreground">{title}</span>
|
|
36
|
+
<span class="block text-xs text-muted-foreground">
|
|
37
|
+
{artifact.open ? 'Writing…' : artifactKind(artifact.mediaType)}
|
|
38
|
+
</span>
|
|
39
|
+
</span>
|
|
40
|
+
{#if artifact.open}
|
|
41
|
+
<Spinner size="sm" label="Writing" />
|
|
42
|
+
{:else}
|
|
43
|
+
<Icon name="chevron-right" size={16} class="text-muted-foreground" />
|
|
44
|
+
{/if}
|
|
45
|
+
</button>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Artifact } from '../reducer.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
artifact: Artifact;
|
|
4
|
+
onopen?: (artifactId: string) => void;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A card-like button for one artifact in a message: type icon, title and kind, and a spinner while
|
|
9
|
+
* the artifact is still being written. Opens it in the artifact pane.
|
|
10
|
+
*/
|
|
11
|
+
declare const ArtifactChip: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type ArtifactChip = ReturnType<typeof ArtifactChip>;
|
|
13
|
+
export default ArtifactChip;
|
|
14
|
+
//# sourceMappingURL=ArtifactChip.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ArtifactChip.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ArtifactChip.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAI5C,UAAU,KAAK;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiCH;;;GAGG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The agent's turn: identity mark, tool timeline, the answer (throttled markdown with a caret
|
|
4
|
+
while streaming), artifacts, UI frames, files, proposed edits, the turn's outcome and actions.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { Alert, Button, Icon } from '@classic-homes/theme-svelte';
|
|
8
|
+
import type { AssistantMessage } from '../reducer.js';
|
|
9
|
+
import { errorTitle } from '../reducer.js';
|
|
10
|
+
import { parseMarkdown } from '../markdown.js';
|
|
11
|
+
import ArtifactChip from './ArtifactChip.svelte';
|
|
12
|
+
import Markdown from './Markdown.svelte';
|
|
13
|
+
import MessageActions from './MessageActions.svelte';
|
|
14
|
+
import MessageFiles from './MessageFiles.svelte';
|
|
15
|
+
import ProposalCard from './ProposalCard.svelte';
|
|
16
|
+
import ToolTimeline from './ToolTimeline.svelte';
|
|
17
|
+
import UiFrame from './UiFrame.svelte';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
message: AssistantMessage;
|
|
21
|
+
streaming?: boolean;
|
|
22
|
+
/** The last message: actions stay visible and the turn can be retried or continued. */
|
|
23
|
+
last?: boolean;
|
|
24
|
+
sandboxOrigin: string;
|
|
25
|
+
fileUrl: (fileId: string) => string;
|
|
26
|
+
onnavigate?: (href: string) => void;
|
|
27
|
+
onopenartifact?: (artifactId: string) => void;
|
|
28
|
+
onprompt?: (text: string) => void;
|
|
29
|
+
ontool?: (toolName: string, params?: Record<string, unknown>) => void;
|
|
30
|
+
onnotify?: (message: string) => void;
|
|
31
|
+
onretry?: () => void;
|
|
32
|
+
/** Ask the agent to pick up a stopped or cut-short answer. */
|
|
33
|
+
oncontinue?: () => void;
|
|
34
|
+
}
|
|
35
|
+
let {
|
|
36
|
+
message,
|
|
37
|
+
streaming = false,
|
|
38
|
+
last = false,
|
|
39
|
+
sandboxOrigin,
|
|
40
|
+
fileUrl,
|
|
41
|
+
onnavigate,
|
|
42
|
+
onopenartifact,
|
|
43
|
+
onprompt,
|
|
44
|
+
ontool,
|
|
45
|
+
onnotify,
|
|
46
|
+
onretry,
|
|
47
|
+
oncontinue,
|
|
48
|
+
}: Props = $props();
|
|
49
|
+
|
|
50
|
+
// Re-parsing markdown on every token is the dominant streaming cost; throttle it.
|
|
51
|
+
const PARSE_THROTTLE_MS = 120;
|
|
52
|
+
let html = $state('');
|
|
53
|
+
let lastParse = 0;
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
$effect(() => {
|
|
56
|
+
const text = message.text;
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (!streaming || now - lastParse >= PARSE_THROTTLE_MS) {
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
timer = null;
|
|
61
|
+
lastParse = now;
|
|
62
|
+
html = parseMarkdown(text);
|
|
63
|
+
} else if (!timer) {
|
|
64
|
+
timer = setTimeout(
|
|
65
|
+
() => {
|
|
66
|
+
timer = null;
|
|
67
|
+
lastParse = Date.now();
|
|
68
|
+
html = parseMarkdown(message.text);
|
|
69
|
+
},
|
|
70
|
+
PARSE_THROTTLE_MS - (now - lastParse),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
$effect(() => () => {
|
|
75
|
+
if (timer) clearTimeout(timer);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function handleClick(event: MouseEvent): void {
|
|
79
|
+
if (!onnavigate) return;
|
|
80
|
+
const anchor = (event.target as HTMLElement | null)?.closest?.('a.agent-internal-link');
|
|
81
|
+
if (!(anchor instanceof HTMLAnchorElement)) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
onnavigate(anchor.getAttribute('href') ?? anchor.href);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const thinking = $derived(
|
|
87
|
+
streaming &&
|
|
88
|
+
!message.text &&
|
|
89
|
+
!message.tools.some((t) => !t.status) &&
|
|
90
|
+
!message.artifacts.some((a) => a.open),
|
|
91
|
+
);
|
|
92
|
+
const canContinue = $derived(last && !!oncontinue);
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<div class="group/message flex gap-3 px-4 py-2" data-role="assistant" data-status={message.status}>
|
|
96
|
+
<span
|
|
97
|
+
class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
>
|
|
100
|
+
<Icon name="sparkles" size={14} />
|
|
101
|
+
</span>
|
|
102
|
+
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
103
|
+
{#if message.tools.length || message.delegations.length}
|
|
104
|
+
<ToolTimeline tools={message.tools} delegations={message.delegations} {streaming} />
|
|
105
|
+
{/if}
|
|
106
|
+
{#if html}<Markdown {html} streaming={streaming && !!message.text} onclick={handleClick} />{/if}
|
|
107
|
+
{#if thinking}
|
|
108
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
|
109
|
+
<span class="flex gap-1" aria-hidden="true">
|
|
110
|
+
<span class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce"></span>
|
|
111
|
+
<span
|
|
112
|
+
class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce [animation-delay:150ms]"
|
|
113
|
+
></span>
|
|
114
|
+
<span
|
|
115
|
+
class="h-1.5 w-1.5 rounded-full bg-current motion-safe:animate-bounce [animation-delay:300ms]"
|
|
116
|
+
></span>
|
|
117
|
+
</span>
|
|
118
|
+
Thinking
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
{#if message.artifacts.length}
|
|
122
|
+
<div class="flex flex-col gap-1.5">
|
|
123
|
+
{#each message.artifacts as artifact (artifact.artifactId)}
|
|
124
|
+
<ArtifactChip {artifact} onopen={onopenartifact} />
|
|
125
|
+
{/each}
|
|
126
|
+
</div>
|
|
127
|
+
{/if}
|
|
128
|
+
{#each message.ui as resource (resource.callId + resource.uri)}
|
|
129
|
+
<UiFrame {resource} {sandboxOrigin} {onprompt} {ontool} {onnotify} />
|
|
130
|
+
{/each}
|
|
131
|
+
{#if message.files.length}<MessageFiles files={message.files} {fileUrl} />{/if}
|
|
132
|
+
{#if message.proposals.length}
|
|
133
|
+
<div class="flex flex-col gap-1.5">
|
|
134
|
+
{#each message.proposals as operation (operation.operationId)}
|
|
135
|
+
<ProposalCard {operation} />
|
|
136
|
+
{/each}
|
|
137
|
+
</div>
|
|
138
|
+
{/if}
|
|
139
|
+
{#if message.error}
|
|
140
|
+
<Alert variant="error">
|
|
141
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
142
|
+
<p class="min-w-0 flex-1">
|
|
143
|
+
<strong class="font-semibold">{errorTitle(message.error.code)}.</strong>
|
|
144
|
+
{message.error.message}
|
|
145
|
+
</p>
|
|
146
|
+
{#if message.error.retryable && onretry}
|
|
147
|
+
<Button variant="outline" size="sm" onclick={onretry}>
|
|
148
|
+
<Icon name="refresh" size={14} /> Try again
|
|
149
|
+
</Button>
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
</Alert>
|
|
153
|
+
{:else if message.status === 'cancelled'}
|
|
154
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
155
|
+
<Icon name="stop" size={14} /> Stopped
|
|
156
|
+
{#if canContinue}
|
|
157
|
+
<Button variant="link" size="sm" class="h-auto p-0" onclick={oncontinue}>Continue</Button>
|
|
158
|
+
{/if}
|
|
159
|
+
</div>
|
|
160
|
+
{:else if message.status === 'incomplete'}
|
|
161
|
+
<Alert variant="warning">
|
|
162
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
163
|
+
<p class="min-w-0 flex-1">The answer was cut short.</p>
|
|
164
|
+
{#if canContinue}
|
|
165
|
+
<Button variant="outline" size="sm" onclick={oncontinue}>Continue</Button>
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
168
|
+
</Alert>
|
|
169
|
+
{/if}
|
|
170
|
+
{#if !streaming && message.status !== 'streaming'}
|
|
171
|
+
<MessageActions
|
|
172
|
+
text={message.text}
|
|
173
|
+
createdAt={message.createdAt}
|
|
174
|
+
visible={last}
|
|
175
|
+
class="-ml-1.5"
|
|
176
|
+
/>
|
|
177
|
+
{/if}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AssistantMessage } from '../reducer.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
message: AssistantMessage;
|
|
4
|
+
streaming?: boolean;
|
|
5
|
+
/** The last message: actions stay visible and the turn can be retried or continued. */
|
|
6
|
+
last?: boolean;
|
|
7
|
+
sandboxOrigin: string;
|
|
8
|
+
fileUrl: (fileId: string) => string;
|
|
9
|
+
onnavigate?: (href: string) => void;
|
|
10
|
+
onopenartifact?: (artifactId: string) => void;
|
|
11
|
+
onprompt?: (text: string) => void;
|
|
12
|
+
ontool?: (toolName: string, params?: Record<string, unknown>) => void;
|
|
13
|
+
onnotify?: (message: string) => void;
|
|
14
|
+
onretry?: () => void;
|
|
15
|
+
/** Ask the agent to pick up a stopped or cut-short answer. */
|
|
16
|
+
oncontinue?: () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The agent's turn: identity mark, tool timeline, the answer (throttled markdown with a caret
|
|
20
|
+
* while streaming), artifacts, UI frames, files, proposed edits, the turn's outcome and actions.
|
|
21
|
+
*/
|
|
22
|
+
declare const AssistantMessage: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type AssistantMessage = ReturnType<typeof AssistantMessage>;
|
|
24
|
+
export default AssistantMessage;
|
|
25
|
+
//# sourceMappingURL=AssistantMessage.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AssistantMessage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AssistantMessage.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAYpD,UAAU,KAAK;IACb,OAAO,EAAE,gBAAgB,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uFAAuF;IACvF,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;CACzB;AAuJH;;;GAGG;AACH,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
<script lang="ts">
|
|
18
18
|
import { AlertDialog, Button, Dialog, Input } from '@classic-homes/theme-svelte';
|
|
19
|
+
import { tick } from 'svelte';
|
|
20
|
+
import { releaseStuckBodyLock } from '../body-lock.js';
|
|
19
21
|
|
|
20
22
|
interface Props {
|
|
21
23
|
renaming?: ConversationRef | null;
|
|
@@ -75,6 +77,17 @@
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
let renameForm = $state<HTMLFormElement | null>(null);
|
|
81
|
+
/** Start in the title field with the text selected, not on the dialog's close button. */
|
|
82
|
+
function focusTitle(event: Event): void {
|
|
83
|
+
const field = renameForm?.querySelector('input');
|
|
84
|
+
if (!field) return;
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
field.focus();
|
|
87
|
+
// The title is written into the field by an effect after this runs; select once it's there.
|
|
88
|
+
void tick().then(() => field.select());
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
function restoreFocus(event: Event): void {
|
|
79
92
|
if (!returnFocus?.isConnected) return;
|
|
80
93
|
event.preventDefault();
|
|
@@ -100,9 +113,13 @@
|
|
|
100
113
|
}}
|
|
101
114
|
title="Rename conversation"
|
|
102
115
|
size="sm"
|
|
103
|
-
|
|
116
|
+
onOpenAutoFocus={focusTitle}
|
|
117
|
+
onCloseAutoFocus={(event) => {
|
|
118
|
+
restoreFocus(event);
|
|
119
|
+
releaseStuckBodyLock();
|
|
120
|
+
}}
|
|
104
121
|
>
|
|
105
|
-
<form id={formId} class="grid gap-2" onsubmit={saveRename}>
|
|
122
|
+
<form bind:this={renameForm} id={formId} class="grid gap-2" onsubmit={saveRename}>
|
|
106
123
|
<Input
|
|
107
124
|
bind:value={draft}
|
|
108
125
|
aria-label="Conversation title"
|
|
@@ -130,5 +147,8 @@
|
|
|
130
147
|
confirmText="Delete"
|
|
131
148
|
confirmVariant="destructive"
|
|
132
149
|
onConfirm={() => void confirmDelete()}
|
|
133
|
-
onCloseAutoFocus={
|
|
150
|
+
onCloseAutoFocus={(event) => {
|
|
151
|
+
restoreFocus(event);
|
|
152
|
+
releaseStuckBodyLock();
|
|
153
|
+
}}
|
|
134
154
|
/>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationDialogs.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConversationDialogs.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;
|
|
1
|
+
{"version":3,"file":"ConversationDialogs.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConversationDialogs.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAQD,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,QAAQ,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;CAClC;AAkHH;;;;;;;GAOG;AACH,QAAA,MAAM,mBAAmB,gEAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Renders sanitized markdown HTML from `parseMarkdown`, with the chat prose rules. The rules are
|
|
4
4
|
Tailwind arbitrary variants on the container rather than a stylesheet: the Classic App's
|
|
5
5
|
cascade-layers polyfill raises the specificity of its base styles above any plain component
|
|
6
|
-
CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat.
|
|
6
|
+
CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat. Tables don't break words (the
|
|
7
|
+
column would squeeze "$1,075,000" to two lines); their wrapper scrolls sideways instead. Code
|
|
8
|
+
blocks get a language label and a copy button; external links an arrow.
|
|
7
9
|
-->
|
|
8
10
|
<script lang="ts" module>
|
|
9
11
|
const PROSE = [
|
|
@@ -14,21 +16,78 @@
|
|
|
14
16
|
'[&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-[1.125em] [&_h3]:font-semibold [&_h3]:leading-snug',
|
|
15
17
|
'[&_h4]:mb-2 [&_h4]:mt-4 [&_h4]:text-[1em] [&_h4]:font-semibold [&_h4]:leading-snug',
|
|
16
18
|
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-primary/80',
|
|
19
|
+
"[&_a.agent-external-link]:after:ml-0.5 [&_a.agent-external-link]:after:text-[0.8em] [&_a.agent-external-link]:after:content-['↗']",
|
|
17
20
|
'[&_ul]:mb-3 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:mb-3 [&_ol]:list-decimal [&_ol]:pl-6',
|
|
18
21
|
'[&_li]:mb-1 [&_li>ul]:mb-0 [&_li>ul]:mt-1 [&_li>ol]:mb-0 [&_li>ol]:mt-1',
|
|
19
22
|
'[&_ul.agent-tasklist]:list-none [&_ul.agent-tasklist]:pl-1',
|
|
20
23
|
'[&_.agent-tasklist_input]:mr-1.5 [&_.agent-tasklist_input]:accent-primary',
|
|
21
24
|
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.875em]',
|
|
22
25
|
'[&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/60 [&_pre]:px-4 [&_pre]:py-3 [&_pre]:text-[13px] [&_pre]:leading-relaxed',
|
|
26
|
+
// Code blocks get a header (language + copy) from `enhanceCode`; the block then drops its own frame.
|
|
27
|
+
'[&_.agent-codeblock]:mb-3 [&_.agent-codeblock]:overflow-hidden [&_.agent-codeblock]:rounded-lg [&_.agent-codeblock]:border [&_.agent-codeblock]:border-border',
|
|
28
|
+
'[&_.agent-codeblock_pre]:mb-0 [&_.agent-codeblock_pre]:rounded-none [&_.agent-codeblock_pre]:border-0',
|
|
23
29
|
'[&_pre_code]:rounded-none [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[length:inherit]',
|
|
24
30
|
'[&_blockquote]:mb-3 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:bg-muted/50 [&_blockquote]:px-4 [&_blockquote]:py-2 [&_blockquote]:text-muted-foreground',
|
|
25
31
|
'[&_hr]:my-5 [&_hr]:border-border',
|
|
26
|
-
'[&
|
|
32
|
+
'[&_.agent-table-wrap]:mb-3 [&_.agent-table-wrap]:max-w-full [&_.agent-table-wrap]:overflow-x-auto',
|
|
33
|
+
'[&_table]:border-collapse [&_table]:text-[0.875em] [&_table]:[overflow-wrap:normal]',
|
|
27
34
|
'[&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:align-top',
|
|
28
35
|
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-1.5 [&_td]:text-left [&_td]:align-top',
|
|
29
36
|
'[&_th]:bg-muted [&_th]:font-semibold',
|
|
30
37
|
'[&_img]:max-w-full [&_img]:rounded-lg',
|
|
31
38
|
].join(' ');
|
|
39
|
+
|
|
40
|
+
/** Blinking caret after the last block while the answer streams. */
|
|
41
|
+
const STREAMING =
|
|
42
|
+
"[&>:last-child]:after:ml-0.5 [&>:last-child]:after:text-muted-foreground [&>:last-child]:after:content-['▍'] motion-safe:[&>:last-child]:after:animate-pulse";
|
|
43
|
+
|
|
44
|
+
const HEADER =
|
|
45
|
+
'flex items-center justify-between gap-2 border-b border-border bg-muted px-3 py-1 text-xs text-muted-foreground';
|
|
46
|
+
const COPY =
|
|
47
|
+
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
48
|
+
const COPY_ICON =
|
|
49
|
+
'<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wrap each rendered code block with a header that names the language and copies the code.
|
|
53
|
+
* Runs on the DOM after `{@html}` updates; the HTML itself stays as DOMPurify left it.
|
|
54
|
+
*/
|
|
55
|
+
export function enhanceCode(root: HTMLElement): void {
|
|
56
|
+
for (const pre of root.querySelectorAll<HTMLPreElement>('pre.agent-code')) {
|
|
57
|
+
if (pre.parentElement?.classList.contains('agent-codeblock')) continue;
|
|
58
|
+
const lang = /agent-lang-([a-z0-9-]+)/.exec(pre.className)?.[1];
|
|
59
|
+
const wrap = document.createElement('div');
|
|
60
|
+
wrap.className = 'agent-codeblock';
|
|
61
|
+
const header = document.createElement('div');
|
|
62
|
+
header.className = HEADER;
|
|
63
|
+
const label = document.createElement('span');
|
|
64
|
+
label.className = 'font-mono';
|
|
65
|
+
label.textContent = lang ?? 'code';
|
|
66
|
+
const copy = document.createElement('button');
|
|
67
|
+
copy.type = 'button';
|
|
68
|
+
copy.className = COPY;
|
|
69
|
+
copy.dataset.agentCopy = '';
|
|
70
|
+
copy.setAttribute('aria-label', lang ? `Copy ${lang} code` : 'Copy code');
|
|
71
|
+
copy.innerHTML = `${COPY_ICON}<span>Copy</span>`;
|
|
72
|
+
header.append(label, copy);
|
|
73
|
+
pre.replaceWith(wrap);
|
|
74
|
+
wrap.append(header, pre);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function copyCode(button: HTMLButtonElement): Promise<void> {
|
|
79
|
+
const code = button.closest('.agent-codeblock')?.querySelector('pre')?.textContent ?? '';
|
|
80
|
+
const text = button.querySelector('span');
|
|
81
|
+
try {
|
|
82
|
+
await navigator.clipboard.writeText(code);
|
|
83
|
+
if (text) text.textContent = 'Copied';
|
|
84
|
+
} catch {
|
|
85
|
+
if (text) text.textContent = 'Copy failed';
|
|
86
|
+
}
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
if (text) text.textContent = 'Copy';
|
|
89
|
+
}, 2000);
|
|
90
|
+
}
|
|
32
91
|
</script>
|
|
33
92
|
|
|
34
93
|
<script lang="ts">
|
|
@@ -38,11 +97,35 @@
|
|
|
38
97
|
/** Output of `parseMarkdown` (already sanitized by DOMPurify). */
|
|
39
98
|
html: string;
|
|
40
99
|
class?: string;
|
|
100
|
+
/** Show a caret after the last block (the answer is still streaming). */
|
|
101
|
+
streaming?: boolean;
|
|
41
102
|
onclick?: (event: MouseEvent) => void;
|
|
42
103
|
}
|
|
43
|
-
let { html, class: className, onclick }: Props = $props();
|
|
104
|
+
let { html, class: className, streaming = false, onclick }: Props = $props();
|
|
105
|
+
|
|
106
|
+
let root: HTMLDivElement | undefined = $state();
|
|
107
|
+
$effect(() => {
|
|
108
|
+
void html;
|
|
109
|
+
if (root) enhanceCode(root);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function handleClick(event: MouseEvent): void {
|
|
113
|
+
const button = (event.target as HTMLElement | null)?.closest?.('button[data-agent-copy]');
|
|
114
|
+
if (button instanceof HTMLButtonElement) {
|
|
115
|
+
void copyCode(button);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
onclick?.(event);
|
|
119
|
+
}
|
|
44
120
|
</script>
|
|
45
121
|
|
|
46
|
-
<!--
|
|
122
|
+
<!-- PROSE stays out of tailwind-merge, which could drop rules it reads as conflicting. Clicks are
|
|
123
|
+
delegated to internal links and code copy buttons inside the HTML; those are focusable. -->
|
|
47
124
|
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
|
48
|
-
<div
|
|
125
|
+
<div
|
|
126
|
+
bind:this={root}
|
|
127
|
+
class={[PROSE, streaming ? STREAMING : '', className ? cn(className) : ''].join(' ')}
|
|
128
|
+
onclick={handleClick}
|
|
129
|
+
>
|
|
130
|
+
{@html html}
|
|
131
|
+
</div>
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap each rendered code block with a header that names the language and copies the code.
|
|
3
|
+
* Runs on the DOM after `{@html}` updates; the HTML itself stays as DOMPurify left it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function enhanceCode(root: HTMLElement): void;
|
|
1
6
|
interface Props {
|
|
2
7
|
/** Output of `parseMarkdown` (already sanitized by DOMPurify). */
|
|
3
8
|
html: string;
|
|
4
9
|
class?: string;
|
|
10
|
+
/** Show a caret after the last block (the answer is still streaming). */
|
|
11
|
+
streaming?: boolean;
|
|
5
12
|
onclick?: (event: MouseEvent) => void;
|
|
6
13
|
}
|
|
7
14
|
/**
|
|
8
15
|
* Renders sanitized markdown HTML from `parseMarkdown`, with the chat prose rules. The rules are
|
|
9
16
|
* Tailwind arbitrary variants on the container rather than a stylesheet: the Classic App's
|
|
10
17
|
* cascade-layers polyfill raises the specificity of its base styles above any plain component
|
|
11
|
-
* CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat.
|
|
18
|
+
* CSS, while utilities still win. Values follow theme-docs `.markdown-content`, sized for chat. Tables don't break words (the
|
|
19
|
+
* column would squeeze "$1,075,000" to two lines); their wrapper scrolls sideways instead. Code
|
|
20
|
+
* blocks get a language label and a copy button; external links an arrow.
|
|
12
21
|
*/
|
|
13
22
|
declare const Markdown: import("svelte").Component<Props, {}, "">;
|
|
14
23
|
type Markdown = ReturnType<typeof Markdown>;
|