@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,118 @@
1
+ import { useEffect } from "react";
2
+
3
+ import type { StreamChunk } from "../core/chunks";
4
+ import type { ChatMessage } from "../core/parts";
5
+ import type { PartRenderProps } from "./part-registry";
6
+ import { ChatProvider, useChatStore } from "../store";
7
+ import { mockAdapter } from "../testing/mock-adapter";
8
+ import { Message } from "./message";
9
+ import { PartRegistryProvider, type PartRegistry } from "./part-registry";
10
+ import { Transcript } from "./transcript";
11
+ // From `ui/`, which primitives may not import — allowed here because the
12
+ // workbench never ships (`layers.test.ts` exempts it by name). Both are styled,
13
+ // and the tool renderer is the SHIPPED one, so a story cannot pass against a
14
+ // card the product does not draw.
15
+ import { PendingIndicator } from "../ui/pending";
16
+ import { ToolRenderer } from "../ui/renderers/tool";
17
+
18
+ /**
19
+ * The workbench the transcript stories share.
20
+ *
21
+ * One set of renderers, not one per story file: a second copy would let a
22
+ * story pass against renderers nothing ships, which is the failure mode a
23
+ * workbench exists to prevent.
24
+ *
25
+ * Light and dark come from the preview's `withThemeByClassName` decorator and
26
+ * its toolbar (`.storybook/preview.tsx`). Do NOT add a switcher or duplicate a
27
+ * story per theme; semantic token classes make one story set correct in both.
28
+ */
29
+
30
+ const bare = "grid gap-3 p-4 text-foreground";
31
+
32
+ function TextPart({ part }: PartRenderProps) {
33
+ if (part.type !== "text") return null;
34
+ return <p className="m-0 whitespace-pre-wrap">{part.text}</p>;
35
+ }
36
+
37
+ function ReasoningPart({ part }: PartRenderProps) {
38
+ if (part.type !== "reasoning") return null;
39
+ return (
40
+ <details>
41
+ <summary className="text-muted-foreground">Thinking</summary>
42
+ <p className="m-0 text-muted-foreground">{part.text}</p>
43
+ </details>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Tool calls render through the SHIPPED renderer, not a copy.
49
+ *
50
+ * This workbench used to keep its own: a pulsing dot, no disclosure, no running
51
+ * border. Every story passed while showing a screen the product no longer had —
52
+ * the exact failure a workbench exists to prevent. `ui/` is reachable here
53
+ * because `layers.test.ts` exempts this file by name, the same allowance
54
+ * `PendingIndicator` already uses.
55
+ *
56
+ * The subagent thread comes with it: `PartRegistryProvider` is a context, so a
57
+ * nested tool resolves through this same registry with nothing re-registered.
58
+ */
59
+ const renderers: PartRegistry = {
60
+ text: TextPart,
61
+ reasoning: ReasoningPart,
62
+ tool: ToolRenderer,
63
+ };
64
+
65
+ export interface HarnessProps {
66
+ readonly script: readonly StreamChunk[];
67
+ readonly delayMs?: number;
68
+ readonly prompt?: string;
69
+ /**
70
+ * Seed the transcript instead of streaming it. `ToolPart.messages` has no
71
+ * chunk in the wire vocabulary — an adapter that ran a subagent populates it
72
+ * directly — so `store.load()` is the only honest way to reach that state.
73
+ */
74
+ readonly history?: readonly ChatMessage[];
75
+ }
76
+
77
+ /** Drives one turn on mount so a story shows a conversation, not an empty box. */
78
+ export function Harness({ script, delayMs = 40, prompt = "Show me the log", history }: HarnessProps) {
79
+ return (
80
+ <ChatProvider adapter={mockAdapter({ script, delayMs })}>
81
+ <PartRegistryProvider renderers={renderers}>
82
+ {history === undefined ? <AutoSend prompt={prompt} /> : <LoadHistory messages={history} />}
83
+ <Transcript
84
+ empty={<p className="text-muted-foreground">No messages yet.</p>}
85
+ className="chat-transcript bg-background"
86
+ pending={() => (
87
+ <PendingIndicator
88
+ className="text-muted-foreground flex items-center gap-2 py-3 text-sm"
89
+ label="Thinking"
90
+ />
91
+ )}
92
+ >
93
+ {(id) => (
94
+ <div className={bare}>
95
+ <Message id={id} />
96
+ </div>
97
+ )}
98
+ </Transcript>
99
+ </PartRegistryProvider>
100
+ </ChatProvider>
101
+ );
102
+ }
103
+
104
+ function AutoSend({ prompt }: { readonly prompt: string }) {
105
+ const store = useChatStore();
106
+ useEffect(() => {
107
+ void store.send({ text: prompt });
108
+ }, [store, prompt]);
109
+ return null;
110
+ }
111
+
112
+ function LoadHistory({ messages }: { readonly messages: readonly ChatMessage[] }) {
113
+ const store = useChatStore();
114
+ useEffect(() => {
115
+ store.load(messages);
116
+ }, [store, messages]);
117
+ return null;
118
+ }
@@ -0,0 +1,112 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { ChatSuggestion } from "../core/capabilities";
4
+ import { emptyDraft } from "../core/outbound";
5
+ import { useCapabilities, useChatStore, useHistoryLoaded, useMessageIds } from "../store";
6
+ import { useTurnActivity } from "./use-turn-activity";
7
+
8
+ export interface SuggestionsProps {
9
+ /**
10
+ * Render one row. Called with the suggestion and the handler that sends it.
11
+ *
12
+ * A render prop rather than a built-in button, for the reason `jumpToLatest`
13
+ * is one: the chrome and the icon are the host's, and this package ships no
14
+ * icon library. A primitive that decides its own chrome is how a primitive
15
+ * turns back into 83 props.
16
+ */
17
+ readonly children: (props: SuggestionRenderProps) => ReactNode;
18
+ /**
19
+ * Wrapped around the rows. Absent ⇒ they render as siblings, which is what a
20
+ * host supplying its own container wants.
21
+ */
22
+ readonly className?: string;
23
+ /**
24
+ * The list's accessible name. Copy is the host's — no locales here.
25
+ *
26
+ * Without it the group is an unnamed landmark and a screen reader announces
27
+ * only "list", with nothing to say what these rows start.
28
+ */
29
+ readonly label?: string;
30
+ }
31
+
32
+ export interface SuggestionRenderProps {
33
+ readonly suggestion: ChatSuggestion;
34
+ /** Send this suggestion's prompt. Already bound; the row only calls it. */
35
+ readonly onSelect: () => void;
36
+ }
37
+
38
+ /**
39
+ * The starter rows for an empty room.
40
+ *
41
+ * Renders NOTHING when the capability is absent, the list is empty, or the room
42
+ * is not empty — never an empty container, which would still spend the parent's
43
+ * `gap` and push the composer down.
44
+ *
45
+ * It owns the emptiness test itself, so every mount point agrees on it.
46
+ */
47
+ /**
48
+ * Whether starter rows will render right now.
49
+ *
50
+ * Exported because the transcript's "no messages yet" line is a FALLBACK for
51
+ * exactly this: when rows are offered they already say the room is empty and
52
+ * name what to do, so the line becomes a caption for the obvious. One predicate,
53
+ * two readers, so the two can never both appear or both vanish.
54
+ */
55
+ export function useHasSuggestions(): boolean {
56
+ const { suggestions } = useCapabilities();
57
+ const ids = useMessageIds();
58
+ const historyLoaded = useHistoryLoaded();
59
+ const activity = useTurnActivity();
60
+
61
+ // The two suppressions `<Transcript>` applies to its own empty state, both
62
+ // bought with incidents: a first turn sent into a fresh room is not an empty
63
+ // room, and an unanswered fetch is not either — a 90-message room would
64
+ // otherwise open on starter prompts while history loads.
65
+ const roomIsEmpty = ids.length === 0 && historyLoaded && activity === null;
66
+
67
+ return roomIsEmpty && (suggestions?.items.length ?? 0) > 0;
68
+ }
69
+
70
+ export function Suggestions({ children, className, label }: SuggestionsProps) {
71
+ const { suggestions } = useCapabilities();
72
+ const store = useChatStore();
73
+ const show = useHasSuggestions();
74
+ const items = suggestions?.items ?? [];
75
+
76
+ if (!show || suggestions === undefined) return null;
77
+
78
+ const { onPick, replyLanguage } = suggestions;
79
+ // `store.send`, not the adapter: it queues behind a running turn and mints the
80
+ // optimistic row, so a picked row and a typed message are one event
81
+ // downstream. `prompt`, never `title` — the title is an abbreviation.
82
+ //
83
+ // The language sentence is appended, not hidden: whatever is sent is what the
84
+ // transcript stores forever, and a hidden marker would need every surface
85
+ // (web, messengers, search, export) to strip it and keep stripping it.
86
+ const pick = (suggestion: ChatSuggestion) => {
87
+ const text = replyLanguage
88
+ ? `${suggestion.prompt} Reply in ${replyLanguage}.`
89
+ : suggestion.prompt;
90
+ void store.send({ ...emptyDraft, text });
91
+ onPick?.(suggestion);
92
+ };
93
+
94
+ // Built here so the JSX below only lays out values that exist: an inline
95
+ // `() => pick(s)` is a new function per row per render.
96
+ const rows = items.map((suggestion) => ({
97
+ id: suggestion.id,
98
+ node: children({ suggestion, onSelect: () => pick(suggestion) }),
99
+ }));
100
+
101
+ // `listitem` on a wrapper, not on the host's row: a button carrying both roles
102
+ // loses one of them depending on the reader.
103
+ return (
104
+ <div className={className} role="list" aria-label={label}>
105
+ {rows.map((row) => (
106
+ <div key={row.id} role="listitem" className="contents">
107
+ {row.node}
108
+ </div>
109
+ ))}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,145 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ import { subagentMessages, type ChatMessage, type ToolPart } from "../core/parts";
4
+ import { MessageContent } from "./message";
5
+
6
+ /**
7
+ * A tool call, as composed parts.
8
+ *
9
+ * The root takes `part` and `children` and nothing else. Everything that would
10
+ * have been `showInput` / `showOutput` / `inputLabel` is a component the host
11
+ * either renders or does not — AI Elements' shape, and the concrete cure for
12
+ * "a boolean prop is a component someone declined to write".
13
+ */
14
+
15
+ const ToolPartContext = createContext<ToolPart | null>(null);
16
+
17
+ function useToolPart(): ToolPart {
18
+ const part = useContext(ToolPartContext);
19
+ if (part === null) throw new Error("tool-call parts must render inside <ToolCall>");
20
+ return part;
21
+ }
22
+
23
+ export interface ToolCallProps {
24
+ readonly part: ToolPart;
25
+ readonly children: ReactNode;
26
+ readonly className?: string;
27
+ /**
28
+ * Disclosure state, as DATA on the root. The host owns the rule; this only
29
+ * publishes the answer, so a stylesheet can turn the chevron without the
30
+ * primitive knowing a chevron exists.
31
+ */
32
+ readonly open?: boolean;
33
+ }
34
+
35
+ export function ToolCall({ part, children, className, open }: ToolCallProps) {
36
+ return (
37
+ <ToolPartContext.Provider value={part}>
38
+ <div
39
+ className={className}
40
+ data-tool={part.toolName}
41
+ data-state={part.toolState.state}
42
+ // Absent rather than `false`: CSS selects on presence, and
43
+ // `data-open="false"` matches `[data-open]` just as well as "true".
44
+ data-open={open === true ? "" : undefined}
45
+ >
46
+ {children}
47
+ </div>
48
+ </ToolPartContext.Provider>
49
+ );
50
+ }
51
+
52
+ export interface ToolCallHeaderProps {
53
+ readonly className?: string;
54
+ /** Receives the live state so a host can label it without reading context. */
55
+ readonly children?: (part: ToolPart) => ReactNode;
56
+ }
57
+
58
+ export function ToolCallHeader({ className, children }: ToolCallHeaderProps) {
59
+ const part = useToolPart();
60
+ return (
61
+ <div className={className}>{children === undefined ? part.toolName : children(part)}</div>
62
+ );
63
+ }
64
+
65
+ export interface ToolCallInputProps {
66
+ readonly className?: string;
67
+ readonly children?: (input: unknown, streaming: boolean) => ReactNode;
68
+ }
69
+
70
+ /**
71
+ * Arguments, including while they stream in.
72
+ *
73
+ * During `input-streaming` the payload is a PARTIAL JSON string, not an object.
74
+ * Handing a host `JSON.parse`d garbage — or throwing — is how a tool card
75
+ * breaks the whole transcript mid-turn, so the raw text is passed through with
76
+ * `streaming` true and the host decides what to show.
77
+ */
78
+ export function ToolCallInput({ className, children }: ToolCallInputProps) {
79
+ const { toolState } = useToolPart();
80
+ const streaming = toolState.state === "input-streaming";
81
+ const input = streaming ? toolState.inputText : toolState.input;
82
+ if (children !== undefined) return <div className={className}>{children(input, streaming)}</div>;
83
+ return <div className={className}>{typeof input === "string" ? input : JSON.stringify(input)}</div>;
84
+ }
85
+
86
+ export interface ToolCallOutputProps {
87
+ readonly className?: string;
88
+ readonly children?: (output: unknown) => ReactNode;
89
+ /** Rendered instead when the call failed. Absent ⇒ the error text is shown. */
90
+ readonly error?: (message: string) => ReactNode;
91
+ }
92
+
93
+ export function ToolCallOutput({ className, children, error }: ToolCallOutputProps) {
94
+ const { toolState } = useToolPart();
95
+
96
+ if (toolState.state === "output-error") {
97
+ return (
98
+ <div className={className} data-error="">
99
+ {error === undefined ? toolState.error : error(toolState.error)}
100
+ </div>
101
+ );
102
+ }
103
+ // Nothing to show yet is NOT an empty output: rendering an empty box while a
104
+ // tool runs reads as "it returned nothing".
105
+ if (toolState.state !== "output-available") return null;
106
+
107
+ const { output } = toolState;
108
+ if (children !== undefined) return <div className={className}>{children(output)}</div>;
109
+ return (
110
+ <div className={className}>{typeof output === "string" ? output : JSON.stringify(output)}</div>
111
+ );
112
+ }
113
+
114
+ export interface SubagentThreadProps {
115
+ readonly className?: string;
116
+ readonly children?: (message: ChatMessage) => ReactNode;
117
+ }
118
+
119
+ /**
120
+ * The subagent's own conversation, nested and read-only.
121
+ *
122
+ * Read-only is a property of the data, not a flag: these messages live inside a
123
+ * completed tool call, so there is no composer and nothing to send. Renders
124
+ * nothing when the tool ran no subagent, which is the ordinary case.
125
+ *
126
+ * Part renderers are inherited from the enclosing registry — see
127
+ * `part-registry.tsx`. A nested thread that had to re-register them would
128
+ * silently lose whichever the host forgot.
129
+ */
130
+ export function SubagentThread({ className, children }: SubagentThreadProps) {
131
+ const messages = subagentMessages(useToolPart());
132
+ if (messages === undefined) return null;
133
+
134
+ return (
135
+ <div className={className} data-subagent="">
136
+ {messages.map((message) =>
137
+ children === undefined ? (
138
+ <MessageContent key={message.id} message={message} />
139
+ ) : (
140
+ <div key={message.id}>{children(message)}</div>
141
+ ),
142
+ )}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@djangocfg/ui-core";
3
+
4
+ export interface ChatTooltipProps {
5
+ /** The hover copy. Falsy → the child renders BARE, keeping `asChild` chains intact. */
6
+ readonly label?: ReactNode;
7
+ readonly children: ReactNode;
8
+ readonly side?: "top" | "bottom" | "left" | "right";
9
+ readonly align?: "start" | "center" | "end";
10
+ }
11
+
12
+ /**
13
+ * Hover name for one icon-only control in this package.
14
+ *
15
+ * The ordinary design-system Tooltip, not the status bar's: that wrapper is
16
+ * shaped for a live multi-line readout, and importing it here would couple a
17
+ * chat control to another surface's presentation.
18
+ *
19
+ * In `primitives` rather than beside the composer because two surfaces use it —
20
+ * the composer row and the per-message action row. A control that names itself
21
+ * on hover is not a composer idea, and leaving it under `composer/` would make
22
+ * the message row import a component whose name says it belongs elsewhere.
23
+ *
24
+ * The provider is mounted app-wide, so this adds none — a second one would give
25
+ * a row its own delay and skip behaviour.
26
+ *
27
+ * Content MIRRORS each control's `aria-label`. Radix links it with
28
+ * `aria-describedby`, so the name is not doubled; a different sentence here
29
+ * would be the only version a pointer user ever reads.
30
+ */
31
+ export function ChatTooltip({
32
+ label,
33
+ children,
34
+ side = "top",
35
+ align = "center",
36
+ }: ChatTooltipProps): ReactElement {
37
+ // Bare, never a Fragment: an outer `PopoverTrigger asChild` clones a single
38
+ // element's ref and a Fragment breaks that clone.
39
+ if (!label) return children as ReactElement;
40
+ return (
41
+ <Tooltip>
42
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
43
+ <TooltipContent side={side} align={align} className="max-w-64">
44
+ {label}
45
+ </TooltipContent>
46
+ </Tooltip>
47
+ );
48
+ }