@djangocfg/widget-chat 0.1.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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- package/src/widgets/media.tsx +239 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { messageCopyText } from "./message-actions";
|
|
4
|
+
import { useChatCopy } from "../i18n";
|
|
5
|
+
import { useComposer } from "../composer/context";
|
|
6
|
+
import { useMessage } from "../store";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What this draft answers, above the field, with a way out.
|
|
10
|
+
*
|
|
11
|
+
* Self-gating like the starter rows beside it: absent target, or a target the
|
|
12
|
+
* transcript no longer holds, renders nothing. A chip naming a message the
|
|
13
|
+
* reader cannot see would describe a quote that will not appear.
|
|
14
|
+
*
|
|
15
|
+
* The quote is resolved from the STORE, not from a capability: the transcript
|
|
16
|
+
* is already in this package, and asking the host to resolve an id it gave us
|
|
17
|
+
* would be a second index that can disagree with the first.
|
|
18
|
+
*/
|
|
19
|
+
export function ChatReplyChip() {
|
|
20
|
+
const copy = useChatCopy().composer;
|
|
21
|
+
const { draft, setReplyTo } = useComposer();
|
|
22
|
+
const target = useMessage(draft.replyTo ?? "");
|
|
23
|
+
|
|
24
|
+
if (draft.replyTo === undefined || target === undefined) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="chat-reply-chip flex w-full items-center gap-2 text-xs">
|
|
28
|
+
<span className="bg-primary/60 h-4 w-0.5 shrink-0 rounded-full" aria-hidden />
|
|
29
|
+
<span className="text-muted-foreground min-w-0 flex-1 truncate">
|
|
30
|
+
{messageCopyText(target)}
|
|
31
|
+
</span>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => setReplyTo(undefined)}
|
|
35
|
+
aria-label={copy.replyClear}
|
|
36
|
+
className="hover:bg-muted shrink-0 rounded p-0.5"
|
|
37
|
+
>
|
|
38
|
+
<X className="h-3 w-3" aria-hidden />
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useChatCopy } from "../i18n";
|
|
4
|
+
import { Suggestions } from "../primitives/suggestions";
|
|
5
|
+
|
|
6
|
+
export interface ChatSuggestionsProps {
|
|
7
|
+
/**
|
|
8
|
+
* Icon KEY → glyph. Absent, or an unmapped key, draws no glyph — a row still
|
|
9
|
+
* works without its picture.
|
|
10
|
+
*
|
|
11
|
+
* A function rather than a map so the host owns the lookup: the catalogue
|
|
12
|
+
* names glyphs as data, and this package ships no icon library. That is the
|
|
13
|
+
* same contract the composer's slash menu takes.
|
|
14
|
+
*/
|
|
15
|
+
readonly icon?: (key: string) => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
Full width with the text left-aligned: the titles differ in length, and
|
|
20
|
+
centred rows of different widths give the eye no shared edge to scan down.
|
|
21
|
+
|
|
22
|
+
`text-left` is not redundant with the flex alignment — a button centres its
|
|
23
|
+
text by default, and that survives `items-center`.
|
|
24
|
+
*/
|
|
25
|
+
const ROW =
|
|
26
|
+
"group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left " +
|
|
27
|
+
"text-[length:var(--chat-text)] transition-colors hover:bg-foreground/5 " +
|
|
28
|
+
"focus-visible:bg-foreground/5 focus-visible:outline-none";
|
|
29
|
+
|
|
30
|
+
const TITLE = "min-w-0 truncate text-muted-foreground group-hover:text-foreground";
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
No gap between rows: they are one column of targets, and a pointer moving down
|
|
34
|
+
it must not cross dead space between them. The rows' own padding is the
|
|
35
|
+
spacing.
|
|
36
|
+
|
|
37
|
+
`pb-3` is the air between the last row and the field, so the group reads as an
|
|
38
|
+
offer above the input rather than part of it.
|
|
39
|
+
|
|
40
|
+
`-ms-3` cancels the row's `px-3` so the glyphs line up with the field's text.
|
|
41
|
+
The LOGICAL property, not `-ml-3`: the row mirrors under `dir="rtl"` on its
|
|
42
|
+
own, and a physical margin would pull it the wrong way.
|
|
43
|
+
*/
|
|
44
|
+
const LIST = "-ms-3 flex flex-col pb-3";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The starter rows, styled.
|
|
48
|
+
*
|
|
49
|
+
* ONE assembly, for the reason `ChatTranscript` is one: a host composing the
|
|
50
|
+
* primitive by hand ships whichever part it forgot, and nothing fails — the
|
|
51
|
+
* feature is simply absent.
|
|
52
|
+
*
|
|
53
|
+
* Mounted in the composer's `above` slot rather than in the transcript. Both
|
|
54
|
+
* are defensible; the composer wins because that is where the user already
|
|
55
|
+
* looks when deciding what to type, and because these rows are an INPUT
|
|
56
|
+
* affordance — sitting where the answer appears made them read as content the
|
|
57
|
+
* room already had.
|
|
58
|
+
*
|
|
59
|
+
* WHEN they show is the primitive's decision, not this slot's: `<Suggestions>`
|
|
60
|
+
* renders nothing unless the room is genuinely empty, so a host passes it
|
|
61
|
+
* unconditionally the way it passes `<AskSurface>`.
|
|
62
|
+
*
|
|
63
|
+
* A vertical list, not the horizontal pills the vendored Vercel primitive uses.
|
|
64
|
+
* The chat pane is often narrow on a wide screen, and pills either wrap into a
|
|
65
|
+
* ragged block or scroll sideways, which hides rows behind a gesture nobody
|
|
66
|
+
* makes on a first visit. Vercel's own docs widget — the shape this was modelled
|
|
67
|
+
* on — is vertical for the same reason.
|
|
68
|
+
*/
|
|
69
|
+
export function ChatSuggestions({ icon }: ChatSuggestionsProps) {
|
|
70
|
+
const copy = useChatCopy().transcript;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Suggestions className={LIST} label={copy.suggestionsLabel}>
|
|
74
|
+
{({ suggestion, onSelect }) => {
|
|
75
|
+
// An absent glyph collapses rather than reserving a gap: a list where
|
|
76
|
+
// only some rows have icons should not indent the rest to match.
|
|
77
|
+
const glyph = icon?.(suggestion.icon ?? "");
|
|
78
|
+
return (
|
|
79
|
+
<button type="button" onClick={onSelect} className={ROW}>
|
|
80
|
+
{glyph}
|
|
81
|
+
<span className={TITLE}>{suggestion.title}</span>
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
}}
|
|
85
|
+
</Suggestions>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ArrowDown } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import type { ChatMessage } from "../core";
|
|
5
|
+
import { fill, useChatCopy } from "../i18n";
|
|
6
|
+
import { Transcript, useHasSuggestions } from "../primitives";
|
|
7
|
+
import { JumpToLatest } from "./jump-to-latest";
|
|
8
|
+
import { ChatMessageView } from "./message";
|
|
9
|
+
import { PendingIndicator } from "./pending";
|
|
10
|
+
import { TurnMark } from "./turn-mark";
|
|
11
|
+
|
|
12
|
+
export interface ChatTranscriptProps {
|
|
13
|
+
/**
|
|
14
|
+
* Rendered under each message's parts — the action row.
|
|
15
|
+
*
|
|
16
|
+
* WHICH actions exist is the host's inventory, so this stays a slot rather
|
|
17
|
+
* than a labels entry.
|
|
18
|
+
*/
|
|
19
|
+
readonly footer?: (message: ChatMessage) => ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Rendered above each message, outside its frame — the speaker's name line.
|
|
22
|
+
*
|
|
23
|
+
* Passed through to the row, which places it: only a ROOM has several
|
|
24
|
+
* speakers to tell apart, and a host with one correspondent passes nothing.
|
|
25
|
+
*/
|
|
26
|
+
readonly header?: (message: ChatMessage) => ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the default empty-room line, for a host that has something truer
|
|
29
|
+
* to say about THIS room than "no messages yet".
|
|
30
|
+
*
|
|
31
|
+
* A slot rather than a labels entry: the host renders a node, not a string,
|
|
32
|
+
* and only the host knows the facts its room carries. The primitive still
|
|
33
|
+
* owns WHEN an empty state may paint — a pending fetch and an in-flight first
|
|
34
|
+
* turn both suppress it — so a host node cannot claim a room is empty while
|
|
35
|
+
* history is still loading.
|
|
36
|
+
*/
|
|
37
|
+
readonly empty?: ReactNode;
|
|
38
|
+
readonly className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The transcript, assembled.
|
|
43
|
+
*
|
|
44
|
+
* ONE assembly, for the reason `ChatComposer` is one: the pending indicator and
|
|
45
|
+
* the jump-to-latest control are render props on the primitive, so a host that
|
|
46
|
+
* composes it by hand ships a transcript missing whichever it forgot — and
|
|
47
|
+
* nothing fails, the feature is simply absent. That is not hypothetical:
|
|
48
|
+
* `jumpToLatest` existed on the primitive with no consumer at all until this
|
|
49
|
+
* assembly filled it.
|
|
50
|
+
*
|
|
51
|
+
* A host needing a different shape still drops to `<Transcript>` and keeps
|
|
52
|
+
* every behaviour; this is the default, not the only door.
|
|
53
|
+
*/
|
|
54
|
+
export function ChatTranscript({
|
|
55
|
+
footer,
|
|
56
|
+
header,
|
|
57
|
+
empty: hostEmpty,
|
|
58
|
+
className,
|
|
59
|
+
}: ChatTranscriptProps) {
|
|
60
|
+
const copy = useChatCopy().transcript;
|
|
61
|
+
const hasSuggestions = useHasSuggestions();
|
|
62
|
+
// A FALLBACK, not a caption. Starter rows above the composer already say the
|
|
63
|
+
// room is empty and name what to do, so the line only earns its place when
|
|
64
|
+
// there are none — a room with no suggestion capability, or one whose filters
|
|
65
|
+
// excluded everything. `null` rather than an empty node: the primitive falls
|
|
66
|
+
// back to rendering the transcript, not a blank box.
|
|
67
|
+
//
|
|
68
|
+
// A host node wins over both. It is checked for `undefined` rather than
|
|
69
|
+
// truthiness because a host that HAS the facts and finds nothing to say
|
|
70
|
+
// returns null, and that must fall through to the default line rather than
|
|
71
|
+
// paint a blank pane.
|
|
72
|
+
//
|
|
73
|
+
// Centred in the pane rather than set at the top-left. A single muted line
|
|
74
|
+
// pinned to a corner of an otherwise empty window reads as a layout that
|
|
75
|
+
// failed, not as an answer; the sentence is all this state has, so it stands
|
|
76
|
+
// where the eye already is. `min-h-full` and not `h-full`: the box must be
|
|
77
|
+
// free to grow with a translation that wraps.
|
|
78
|
+
const fallback = hasSuggestions ? null : (
|
|
79
|
+
<div className="flex min-h-full items-center justify-center p-8">
|
|
80
|
+
<p className="text-muted-foreground text-center text-sm text-balance">{copy.empty}</p>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
const empty = hostEmpty === undefined ? fallback : hostEmpty;
|
|
84
|
+
|
|
85
|
+
// The announcer takes a fixed pair. Built here rather than inline in JSX so
|
|
86
|
+
// the object identity is stable per render pass, matching rule 5.
|
|
87
|
+
const announce = { streaming: copy.announceStreaming, complete: copy.announceComplete };
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Transcript
|
|
91
|
+
/*
|
|
92
|
+
The SCROLLER spans the pane; the reading column caps its CONTENTS.
|
|
93
|
+
Capping the scroller itself would pull the scrollbar into the middle of
|
|
94
|
+
the window, away from the edge where a pointer expects it.
|
|
95
|
+
|
|
96
|
+
`chat-surface` carries the type scale. The width is `chat-column`, and
|
|
97
|
+
it belongs on the CONTENT wrapper inside — the primitive puts it there,
|
|
98
|
+
because the rule caps direct children and the rows are that element's
|
|
99
|
+
children. Here it would cap the wrapper alone and cost nothing.
|
|
100
|
+
|
|
101
|
+
`pb-10`: without it the last line ends a few pixels above the composer
|
|
102
|
+
and the two read as one surface. On the SCROLLER, not the last row — a
|
|
103
|
+
margin there is eaten by the sticky-bottom write.
|
|
104
|
+
*/
|
|
105
|
+
className={`chat-surface min-h-0 ${className ?? ""}`}
|
|
106
|
+
empty={empty}
|
|
107
|
+
label={copy.log}
|
|
108
|
+
announce={announce}
|
|
109
|
+
// Shown only while scrolled away — the transcript owns that gate, so this
|
|
110
|
+
// never decides its own visibility.
|
|
111
|
+
jumpToLatest={({ onClick, missed }) => (
|
|
112
|
+
<JumpToLatest
|
|
113
|
+
onClick={onClick}
|
|
114
|
+
missed={missed}
|
|
115
|
+
label={copy.jumpToLatest}
|
|
116
|
+
icon={<ArrowDown className="size-4" aria-hidden />}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
// The answer will land here, so the promise of one belongs here too — not
|
|
120
|
+
// on the Stop button in the far corner, which is otherwise the only
|
|
121
|
+
// evidence a sent turn produced.
|
|
122
|
+
//
|
|
123
|
+
// It spans the WHOLE turn. While `waiting` it carries the label and the
|
|
124
|
+
// elapsed counter, because nothing else is on screen to explain the pause;
|
|
125
|
+
// once text is `working` the answer speaks for itself, so the row drops to
|
|
126
|
+
// the mark alone and trails the reply instead of captioning it.
|
|
127
|
+
pending={(activity) =>
|
|
128
|
+
activity === "waiting" ? (
|
|
129
|
+
<PendingIndicator
|
|
130
|
+
// Body size, not `text-sm`. This line stands exactly where the reply
|
|
131
|
+
// will appear, so a smaller one announces the answer in a different
|
|
132
|
+
// voice from the answer — and Tailwind's fixed 14px ignores the chat
|
|
133
|
+
// scale entirely, so it drifted further the moment the body grew.
|
|
134
|
+
className="text-muted-foreground flex items-center gap-2 py-3 text-[length:var(--chat-text)]"
|
|
135
|
+
label={copy.thinking}
|
|
136
|
+
elapsed={(seconds) => fill(copy.thinkingElapsed, { seconds })}
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="text-muted-foreground py-1">
|
|
140
|
+
<TurnMark state="working" />
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
>
|
|
145
|
+
{/*
|
|
146
|
+
The styled row, not the headless `<Message>`: it carries the bubble
|
|
147
|
+
decision — user turns get one, replies stay open text — and declares the
|
|
148
|
+
role once so descendants read it through `group-[.is-user]:` instead of
|
|
149
|
+
taking a `from` prop each.
|
|
150
|
+
*/}
|
|
151
|
+
{/* No vertical padding: the transcript's own `gap` is the one rhythm
|
|
152
|
+
between messages. Padding here would add to it unevenly, since a row's
|
|
153
|
+
height already varies with the optional speaker line. */}
|
|
154
|
+
{(id) => <ChatMessageView id={id} footer={footer} header={header} />}
|
|
155
|
+
</Transcript>
|
|
156
|
+
);
|
|
157
|
+
}
|
package/src/ui/chat.tsx
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ChatAdapter } from "../core/adapter";
|
|
4
|
+
import type { ChatCapabilities } from "../core/capabilities";
|
|
5
|
+
import type { ChatMessage } from "../core/parts";
|
|
6
|
+
import { ChatProvider, useChatStore } from "../store";
|
|
7
|
+
import { PartRegistryProvider, type PartRegistry } from "../primitives";
|
|
8
|
+
import { ChatComposer } from "./chat-composer";
|
|
9
|
+
import { ChatTranscript } from "./chat-transcript";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The packaged chat.
|
|
13
|
+
*
|
|
14
|
+
* SEVEN props, and that number is the whole design. The predecessor reached 83,
|
|
15
|
+
* each documented "omit ⇒ feature off", which produced a configuration space no
|
|
16
|
+
* one could enumerate and bugs that lived in untested combinations. Here a
|
|
17
|
+
* feature arrives as a capability, a part renderer, or a slot — never as a
|
|
18
|
+
* prop, so the count does not move as the product grows
|
|
19
|
+
* (`@rules/architecture.md`).
|
|
20
|
+
*
|
|
21
|
+
* `<Chat>` is a convenience, not the API. A host that needs a different layout
|
|
22
|
+
* drops to `<ChatProvider>` plus primitives and keeps every behaviour; that
|
|
23
|
+
* escape hatch is what keeps this component from accreting props for layouts it
|
|
24
|
+
* was never meant to cover.
|
|
25
|
+
*/
|
|
26
|
+
interface ChatBase {
|
|
27
|
+
readonly adapter: ChatAdapter;
|
|
28
|
+
/** Present ⇒ the feature exists. There is no boolean to contradict it. */
|
|
29
|
+
readonly capabilities?: ChatCapabilities;
|
|
30
|
+
/** `part.type` → renderer. Absent types render nothing rather than crashing. */
|
|
31
|
+
readonly renderers?: PartRegistry;
|
|
32
|
+
/** Which conversation this is, and the turns it opens with. */
|
|
33
|
+
readonly conversation?: ChatConversation;
|
|
34
|
+
/** Named insertion points: `header`, `toolbarEnd`, `footer`, `messageFooter`. */
|
|
35
|
+
readonly slots?: ChatSlots;
|
|
36
|
+
readonly className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Seven props, counted honestly — the budget test reads this file.
|
|
41
|
+
*
|
|
42
|
+
* `body` stays ONE prop rather than a `children` beside a boolean: the packaged
|
|
43
|
+
* layout and a host's own composition are mutually exclusive, so they were
|
|
44
|
+
* never two decisions. One prop, one union, both mistakes unrepresentable.
|
|
45
|
+
*/
|
|
46
|
+
export interface ChatProps extends ChatBase {
|
|
47
|
+
readonly body: ChatBody;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The conversation on screen: which room, and the turns it opens with.
|
|
52
|
+
*
|
|
53
|
+
* ONE prop rather than `history` beside a `room`, because they are one
|
|
54
|
+
* decision. A host never changes the room without changing the turns, and the
|
|
55
|
+
* two arriving separately is what made "the conversation changed" a thing the
|
|
56
|
+
* store had to INFER from a new array identity — an inference that covered the
|
|
57
|
+
* transcript and silently missed the rest of the room's state.
|
|
58
|
+
*
|
|
59
|
+
* `id` is what the store compares. Omit it and the seam behaves exactly as the
|
|
60
|
+
* bare history array did: a new array reseeds the transcript and nothing else.
|
|
61
|
+
* Supply it and a room change also clears what belongs to the room it left.
|
|
62
|
+
*/
|
|
63
|
+
export interface ChatConversation {
|
|
64
|
+
/**
|
|
65
|
+
* Stable identity of the room. Any spelling the host likes, as long as two
|
|
66
|
+
* different rooms never share one — it is compared, never parsed.
|
|
67
|
+
*/
|
|
68
|
+
readonly id?: string;
|
|
69
|
+
/** Prior turns to seed. `undefined` = not loaded yet, `[]` = empty room. */
|
|
70
|
+
readonly history?: readonly ChatMessage[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChatSlots {
|
|
74
|
+
readonly header?: ReactNode;
|
|
75
|
+
readonly toolbarEnd?: ReactNode;
|
|
76
|
+
readonly footer?: ReactNode;
|
|
77
|
+
/**
|
|
78
|
+
* The per-message action row. A slot, not a label: WHICH actions exist is the
|
|
79
|
+
* host's inventory, and a flag per action could never express one the package
|
|
80
|
+
* has not heard of.
|
|
81
|
+
*/
|
|
82
|
+
readonly messageFooter?: (message: ChatMessage) => ReactNode;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const noRenderers: PartRegistry = {};
|
|
86
|
+
|
|
87
|
+
export function Chat({
|
|
88
|
+
adapter,
|
|
89
|
+
capabilities,
|
|
90
|
+
renderers = noRenderers,
|
|
91
|
+
conversation,
|
|
92
|
+
slots,
|
|
93
|
+
className,
|
|
94
|
+
body,
|
|
95
|
+
}: ChatProps) {
|
|
96
|
+
return (
|
|
97
|
+
<ChatProvider adapter={adapter} capabilities={capabilities}>
|
|
98
|
+
<PartRegistryProvider renderers={renderers}>
|
|
99
|
+
<ChatLayout
|
|
100
|
+
conversation={conversation}
|
|
101
|
+
slots={slots}
|
|
102
|
+
className={className}
|
|
103
|
+
{...body}
|
|
104
|
+
/>
|
|
105
|
+
</PartRegistryProvider>
|
|
106
|
+
</ChatProvider>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What fills the chat: the packaged layout, or the host's own composition.
|
|
112
|
+
*
|
|
113
|
+
* `packaged` needs no payload — the copy comes from the package's own
|
|
114
|
+
* dictionary, so there is nothing left for a host to supply.
|
|
115
|
+
*/
|
|
116
|
+
export type ChatBody =
|
|
117
|
+
| { readonly kind: "packaged" }
|
|
118
|
+
| { readonly kind: "custom"; readonly children: ReactNode };
|
|
119
|
+
|
|
120
|
+
type ChatLayoutProps = {
|
|
121
|
+
readonly conversation?: ChatConversation;
|
|
122
|
+
readonly slots?: ChatSlots;
|
|
123
|
+
readonly className?: string;
|
|
124
|
+
} & ChatBody;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Inside the provider, because seeding needs the store.
|
|
128
|
+
*
|
|
129
|
+
* A custom body REPLACES the default rather than adding to it: a host passing
|
|
130
|
+
* its own composition wants exactly that, and merging the two would make every
|
|
131
|
+
* custom layout start by hiding parts of the default one.
|
|
132
|
+
*/
|
|
133
|
+
function ChatLayout({ conversation, slots, className, ...body }: ChatLayoutProps) {
|
|
134
|
+
useConversation(conversation);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
// `relative` is stated here rather than left to the host's `className`: the
|
|
138
|
+
// surfaces that position against this pane are this component's own
|
|
139
|
+
// children, so a host that forgot it would have them resolve against the
|
|
140
|
+
// viewport and cover the whole page.
|
|
141
|
+
<div className={`relative ${className ?? ""}`}>
|
|
142
|
+
{slots?.header}
|
|
143
|
+
{body.kind === "custom" ? (
|
|
144
|
+
body.children
|
|
145
|
+
) : (
|
|
146
|
+
<>
|
|
147
|
+
<ChatTranscript footer={slots?.messageFooter} />
|
|
148
|
+
<ChatComposer toolbarEnd={slots?.toolbarEnd} />
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
{slots?.footer}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Seed the transcript, and leave the previous room behind when the room changes.
|
|
158
|
+
*
|
|
159
|
+
* Seeding is keyed on array identity, not contents: `load` replaces the
|
|
160
|
+
* transcript wholesale, so running it on every render would discard a live turn.
|
|
161
|
+
* A host that appends to its history array gets one reload, which is the
|
|
162
|
+
* intended behaviour.
|
|
163
|
+
*
|
|
164
|
+
* The ROOM change is a separate signal and does more, because `load` only ever
|
|
165
|
+
* replaced the transcript. A room also owns its queued prompts and its in-flight
|
|
166
|
+
* turn, and both used to survive on the host's mount — which is why hosts
|
|
167
|
+
* destroyed the whole chat on every switch. Without this reset, dropping that
|
|
168
|
+
* remount would carry one room's "thinking" indicator and another room's queued
|
|
169
|
+
* text into the conversation the user just opened.
|
|
170
|
+
*
|
|
171
|
+
* Ordering is load-bearing: the reset runs BEFORE the seed, in one effect rather
|
|
172
|
+
* than two. Two effects would fire in declaration order on the first render of a
|
|
173
|
+
* new room and blank the transcript the seed had just placed.
|
|
174
|
+
*/
|
|
175
|
+
function useConversation(conversation: ChatConversation | undefined): void {
|
|
176
|
+
const store = useChatStore();
|
|
177
|
+
const history = conversation?.history;
|
|
178
|
+
const roomId = conversation?.id;
|
|
179
|
+
// The room this store is currently showing. A ref, not state: nothing renders
|
|
180
|
+
// from it, and it must be readable and writable inside the same effect pass.
|
|
181
|
+
const shownRoom = useRef(roomId);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (shownRoom.current !== roomId) {
|
|
185
|
+
shownRoom.current = roomId;
|
|
186
|
+
// Aborts an in-flight turn and drops the queue. A turn belongs to the room
|
|
187
|
+
// it was sent to; abandoning it silently is what already happened when the
|
|
188
|
+
// host remounted, so this preserves the behaviour rather than changing it.
|
|
189
|
+
store.leaveRoom();
|
|
190
|
+
}
|
|
191
|
+
// `undefined` is "not loaded yet", and the store must be told: a transcript
|
|
192
|
+
// that only knows "no messages" shows the empty state during the fetch, so
|
|
193
|
+
// a room with 90 replies opened on an empty state for ~100ms.
|
|
194
|
+
store.setHistoryLoaded(history !== undefined);
|
|
195
|
+
if (history !== undefined) store.load(history);
|
|
196
|
+
}, [history, roomId, store]);
|
|
197
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useCallback, useId, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@djangocfg/ui-core/lib";
|
|
4
|
+
|
|
5
|
+
import { useChatCopy } from "../i18n";
|
|
6
|
+
import { useOverflows } from "../primitives/use-overflows";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rows of body text a turn may occupy before it is offered a collapse.
|
|
10
|
+
*
|
|
11
|
+
* ROWS, because the question is how much SCREEN a turn costs — and that is a
|
|
12
|
+
* height, not a character count. `useOverflows` measures the rendered element
|
|
13
|
+
* (`scrollHeight > clientHeight`), so an image, a table or a code fence is
|
|
14
|
+
* counted by what it actually occupies rather than by how much text it carries.
|
|
15
|
+
*
|
|
16
|
+
* 10 is measured, not guessed. Against a real 100-turn transcript: the median
|
|
17
|
+
* turn is ONE row, the 90th percentile is 9, and the tallest is 186. At the
|
|
18
|
+
* original 18 exactly one turn in a hundred was ever offered a collapse, which
|
|
19
|
+
* reads as the feature being broken rather than as messages being short. At 10,
|
|
20
|
+
* eight are — every genuinely long answer, and no ordinary reply.
|
|
21
|
+
*
|
|
22
|
+
* Below 8 the numbers stop moving (8 and 6 both collapse 11), so there is a gap
|
|
23
|
+
* in real message lengths and nothing to buy by going lower.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_MAX_ROWS = 10;
|
|
26
|
+
|
|
27
|
+
export interface CollapseState {
|
|
28
|
+
/** Is the height budget being enforced right now? */
|
|
29
|
+
readonly clamped: boolean;
|
|
30
|
+
/** Is there a control to open or close the turn? */
|
|
31
|
+
readonly showTrigger: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The clamp/trigger policy, as a pure function.
|
|
36
|
+
*
|
|
37
|
+
* Split out because this package's tests run without a DOM — `scrollHeight` is
|
|
38
|
+
* not merely absent, it is meaningless here — so the DOM READ is verified in a
|
|
39
|
+
* browser and the DECISION is verified as data. The same split the transcript's
|
|
40
|
+
* select-all rule already uses, for the same reason.
|
|
41
|
+
*/
|
|
42
|
+
export function collapseState(
|
|
43
|
+
streaming: boolean,
|
|
44
|
+
expanded: boolean,
|
|
45
|
+
overflows: boolean,
|
|
46
|
+
mayCollapse = true,
|
|
47
|
+
): CollapseState {
|
|
48
|
+
// Ineligible wins over everything: the newest turn is shown whole, with no
|
|
49
|
+
// control, however tall it is. Inside the function rather than `&&`-ed onto
|
|
50
|
+
// its result so the rule is one decision in one place — and so a mutation
|
|
51
|
+
// that drops it fails a test instead of surviving in a caller.
|
|
52
|
+
if (!mayCollapse) return { clamped: false, showTrigger: false };
|
|
53
|
+
// Streaming wins over the rest. A reply must not fold while it is still
|
|
54
|
+
// arriving, and its height changes on every chunk, so a measurement taken
|
|
55
|
+
// mid-stream would flip the control on and off as the answer grows.
|
|
56
|
+
if (streaming) return { clamped: false, showTrigger: false };
|
|
57
|
+
return { clamped: !expanded, showTrigger: overflows };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
/** A turn's collapse, owned by the row so a body and a toolbar can share it. */
|
|
62
|
+
export interface Collapse extends CollapseState {
|
|
63
|
+
readonly bodyRef: React.RefObject<HTMLDivElement | null>;
|
|
64
|
+
readonly bodyId: string;
|
|
65
|
+
readonly expanded: boolean;
|
|
66
|
+
readonly toggle: () => void;
|
|
67
|
+
readonly label: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Own one turn's collapse.
|
|
72
|
+
*
|
|
73
|
+
* The state lives HERE, in the row, rather than inside the body — because the
|
|
74
|
+
* body and the toolbar are siblings inside the frame, and a control cannot be
|
|
75
|
+
* handed upward from one to the other. The row is their nearest common owner.
|
|
76
|
+
*
|
|
77
|
+
* That placement is also the fix for where the control sits. Rendered under the
|
|
78
|
+
* body it lands ON the clipped line: the reader sees a half-cut row of text
|
|
79
|
+
* with a label overlapping it. In the toolbar it joins the actions that already
|
|
80
|
+
* belong to the turn.
|
|
81
|
+
*
|
|
82
|
+
* `collapsible` is FROZEN at mount, which is what makes a positional rule safe.
|
|
83
|
+
* The newest reply is left open — nothing below it to scroll past, and folding
|
|
84
|
+
* the one thing the reader is waiting for is the opposite of the point. But a
|
|
85
|
+
* POSITION changes: send another message and the previously-last turn is no
|
|
86
|
+
* longer last. Read live, that would collapse the answer someone is mid-way
|
|
87
|
+
* through. `useState`'s initialiser runs once, so a turn keeps what it was born
|
|
88
|
+
* with — a later `true` never folds it, a later `false` never opens it.
|
|
89
|
+
*/
|
|
90
|
+
export function useCollapse(
|
|
91
|
+
streaming: boolean,
|
|
92
|
+
revision: unknown,
|
|
93
|
+
collapsible = true,
|
|
94
|
+
): Collapse {
|
|
95
|
+
const copy = useChatCopy().message;
|
|
96
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
97
|
+
const [expanded, setExpanded] = useState(false);
|
|
98
|
+
const [mayCollapse] = useState(collapsible);
|
|
99
|
+
const bodyId = useId();
|
|
100
|
+
|
|
101
|
+
const overflows = useOverflows(bodyRef, expanded, revision);
|
|
102
|
+
const toggle = useCallback(() => {
|
|
103
|
+
setExpanded((open) => !open);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const state = collapseState(streaming, expanded, overflows, mayCollapse);
|
|
107
|
+
return {
|
|
108
|
+
bodyRef,
|
|
109
|
+
bodyId,
|
|
110
|
+
expanded,
|
|
111
|
+
toggle,
|
|
112
|
+
...state,
|
|
113
|
+
label: expanded ? copy.showLess : copy.showMore,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bound a long turn to a readable height.
|
|
119
|
+
*
|
|
120
|
+
* Two properties are load-bearing and easy to lose in a refactor.
|
|
121
|
+
*
|
|
122
|
+
* Collapsed content is CLIPPED, never unmounted. The transcript deliberately
|
|
123
|
+
* runs without virtualization partly to keep find-in-page and select-all whole,
|
|
124
|
+
* and removing text from the DOM to save height breaks both in exactly the way
|
|
125
|
+
* that decision avoids.
|
|
126
|
+
*
|
|
127
|
+
* The trigger appears only when the content genuinely overflows — the height
|
|
128
|
+
* budget is a fact about this turn, not a decoration, and "show more" under a
|
|
129
|
+
* short message teases a disclosure that reveals nothing.
|
|
130
|
+
*/
|
|
131
|
+
export function CollapsibleBody({
|
|
132
|
+
collapse,
|
|
133
|
+
maxRows = DEFAULT_MAX_ROWS,
|
|
134
|
+
className,
|
|
135
|
+
children,
|
|
136
|
+
}: {
|
|
137
|
+
readonly collapse: Collapse;
|
|
138
|
+
readonly maxRows?: number;
|
|
139
|
+
readonly className?: string;
|
|
140
|
+
readonly children: ReactNode;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
id={collapse.bodyId}
|
|
145
|
+
ref={collapse.bodyRef}
|
|
146
|
+
className={cn("min-w-0", collapse.clamped && "overflow-hidden", className)}
|
|
147
|
+
// The budget is a HEIGHT in rows, because rows are what the reader
|
|
148
|
+
// experiences. Same two tokens the composer sizes its controls from, so a
|
|
149
|
+
// host that scales the chat scales this with it.
|
|
150
|
+
//
|
|
151
|
+
// No gradient fade over the cut: a fade must know the colour behind it,
|
|
152
|
+
// and there are two — the user's bubble on `bg-secondary`, the reply on
|
|
153
|
+
// the page — in both themes. A wrong one reads as a seam across the last
|
|
154
|
+
// visible line, and the label already says the turn continues.
|
|
155
|
+
style={
|
|
156
|
+
collapse.clamped
|
|
157
|
+
? { maxHeight: `calc(${maxRows} * var(--chat-text, 1rem) * var(--chat-leading, 1.65))` }
|
|
158
|
+
: undefined
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The control. Placed by the row, in the toolbar beside the turn's actions. */
|
|
167
|
+
export function CollapseTrigger({ collapse }: { readonly collapse: Collapse }) {
|
|
168
|
+
if (!collapse.showTrigger) return null;
|
|
169
|
+
return (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={collapse.toggle}
|
|
173
|
+
aria-expanded={collapse.expanded}
|
|
174
|
+
aria-controls={collapse.bodyId}
|
|
175
|
+
className="text-muted-foreground hover:text-foreground cursor-pointer text-[length:var(--chat-text-small)] underline-offset-4 hover:underline"
|
|
176
|
+
>
|
|
177
|
+
{collapse.label}
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
}
|