@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,116 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Loader2 } from "lucide-react";
3
+ import {
4
+ ComposerBanner,
5
+ ComposerBannerBody,
6
+ ComposerBannerDescription,
7
+ ComposerBannerIcon,
8
+ ComposerBannerTitle,
9
+ } from "./banner";
10
+
11
+ /**
12
+ * `<CommandProgress>` — "this session-control verb is still running".
13
+ *
14
+ * A control verb is not a turn: it opens no streaming message, so the
15
+ * transcript's pending row never appears for it and the composer shows nothing.
16
+ * The verb also takes real time — an engine walking a transcript is tens of
17
+ * seconds — which leaves a person who pressed Enter with an emptied field, no
18
+ * row in the conversation, and no evidence anything is happening. They press it
19
+ * again.
20
+ *
21
+ * Above the composer rather than in the transcript, and that follows the
22
+ * existing split rather than taste: `ComposerBanner` is where a notice about
23
+ * the SESSION goes, while the transcript's pending row belongs to a turn. A row
24
+ * in the transcript would also have to be removed again on completion, since
25
+ * the verb writes no message the row could resolve into.
26
+ *
27
+ * Copy is the HOST's, like every other banner here: which verbs exist comes
28
+ * from a generated catalogue this package does not read, and the sentence for
29
+ * one does not template across locales.
30
+ *
31
+ * The elapsed counter is the load-bearing half. A spinner alone says "busy" and
32
+ * says it identically at two seconds and at two minutes, which is exactly the
33
+ * ambiguity that makes a person retry; a climbing number says the wait is
34
+ * progressing rather than hung.
35
+ */
36
+ export interface CommandProgressProps {
37
+ /**
38
+ * The verb in flight, or `null` when nothing is running. Null renders
39
+ * nothing, so this can be mounted unconditionally beside the other slots.
40
+ */
41
+ readonly verb: string | null;
42
+ /** What is happening, already localised — "Compacting the conversation…". */
43
+ readonly title: string;
44
+ /**
45
+ * Given whole seconds waited, the counter's text. Called only after the quiet
46
+ * threshold passes.
47
+ */
48
+ readonly elapsed: (seconds: number) => string;
49
+ /**
50
+ * How long to stay quiet before showing a number. A verb that finishes fast
51
+ * should leave no trace of a counter at all — a digit that appears and
52
+ * vanishes reads as a glitch.
53
+ */
54
+ readonly elapsedAfterMs?: number;
55
+ }
56
+
57
+ const DEFAULT_ELAPSED_AFTER_MS = 3_000;
58
+
59
+ export function CommandProgress({
60
+ verb,
61
+ title,
62
+ elapsed,
63
+ elapsedAfterMs = DEFAULT_ELAPSED_AFTER_MS,
64
+ }: CommandProgressProps) {
65
+ const seconds = useElapsedSeconds(verb, elapsedAfterMs);
66
+
67
+ if (verb === null) return null;
68
+
69
+ const counter = seconds === null ? null : elapsed(seconds);
70
+
71
+ return (
72
+ <ComposerBanner>
73
+ <ComposerBannerIcon tone="info">
74
+ <Loader2 className="size-4 animate-spin" aria-hidden />
75
+ </ComposerBannerIcon>
76
+ <ComposerBannerBody>
77
+ <ComposerBannerTitle>{title}</ComposerBannerTitle>
78
+ {counter === null ? null : (
79
+ <ComposerBannerDescription>
80
+ <span className="tabular-nums">{counter}</span>
81
+ </ComposerBannerDescription>
82
+ )}
83
+ </ComposerBannerBody>
84
+ </ComposerBanner>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Whole seconds since THIS verb started, or `null` under the threshold.
90
+ *
91
+ * Keyed on the verb rather than on mount: the banner stays mounted between two
92
+ * runs in the same room, so a mount-time start would report the age of the
93
+ * previous wait. Re-running the effect on the verb restarts the clock, and a
94
+ * `null` verb stops the interval rather than leaving it ticking behind an idle
95
+ * room.
96
+ */
97
+ function useElapsedSeconds(verb: string | null, afterMs: number): number | null {
98
+ const startedAt = useRef(0);
99
+ const [seconds, setSeconds] = useState<number | null>(null);
100
+
101
+ useEffect(() => {
102
+ if (verb === null) {
103
+ setSeconds(null);
104
+ return;
105
+ }
106
+ startedAt.current = Date.now();
107
+ setSeconds(null);
108
+ const id = setInterval(() => {
109
+ const waited = Date.now() - startedAt.current;
110
+ setSeconds(waited < afterMs ? null : Math.floor(waited / 1_000));
111
+ }, 1_000);
112
+ return () => clearInterval(id);
113
+ }, [verb, afterMs]);
114
+
115
+ return seconds;
116
+ }
@@ -0,0 +1,104 @@
1
+ import { X } from "lucide-react";
2
+ import { memo, useCallback, useRef, type ReactNode } from "react";
3
+
4
+ import type { ToolPick } from "../../core/tool-picks";
5
+ import { focusComposerField } from "../focus-field";
6
+ import { useChatCopy } from "../../i18n";
7
+ import { useComposer } from "../context";
8
+
9
+ export interface ComposerPickedToolsProps {
10
+ /** Glyph for a pick's icon KEY. Absent, or unmapped, ⇒ a chip with no glyph. */
11
+ readonly icon?: (key: string) => ReactNode;
12
+ readonly className?: string;
13
+ readonly chipClassName?: string;
14
+ }
15
+
16
+ /**
17
+ * The tools staged on this draft, one chip each.
18
+ *
19
+ * Beside the `+` rather than inside the field: a pick has no position in the
20
+ * sentence, and a token spliced into the text would move as the user edits
21
+ * around it. Renders null when nothing is picked, so a host laying out the
22
+ * control row gets no empty box to undo.
23
+ */
24
+ export function ComposerPickedTools({ icon, className, chipClassName }: ComposerPickedToolsProps) {
25
+ const { draft, removePickedTool } = useComposer();
26
+ const copy = useChatCopy().composer;
27
+ // A draft restored from storage predates this field; absent is "none picked".
28
+ const picked = draft.pickedTools ?? [];
29
+ if (picked.length === 0) return null;
30
+
31
+ return (
32
+ // Named, because a tool's label appears twice on screen — here and on its
33
+ // menu row — and without one neither a reader nor a test can say which.
34
+ <ul aria-label={copy.toolsPicked} className={className}>
35
+ {picked.map((pick) => (
36
+ <li key={pick.id}>
37
+ <PickedToolChip
38
+ pick={pick}
39
+ icon={icon}
40
+ onRemove={removePickedTool}
41
+ className={chipClassName}
42
+ />
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ );
47
+ }
48
+
49
+ export interface PickedToolChipProps {
50
+ readonly pick: ToolPick;
51
+ readonly icon?: (key: string) => ReactNode;
52
+ readonly onRemove: (id: string) => void;
53
+ readonly className?: string;
54
+ }
55
+
56
+ /** One chip. Memoized so typing does not re-render the staged row. */
57
+ export const PickedToolChip = memo(function PickedToolChip({
58
+ pick,
59
+ icon,
60
+ onRemove,
61
+ className,
62
+ }: PickedToolChipProps) {
63
+ const copy = useChatCopy().composer;
64
+ const removeRef = useRef<HTMLButtonElement>(null);
65
+ // The chip goes with the click, taking focus to `body` and dropping the next
66
+ // keystroke. Deferred for the reason the tool menu defers: this element
67
+ // unmounts in the same tick, and its blur would undo the focus.
68
+ const remove = useCallback(() => {
69
+ const form = removeRef.current?.closest("form");
70
+ onRemove(pick.id);
71
+ queueMicrotask(() => focusComposerField(form));
72
+ }, [onRemove, pick.id]);
73
+
74
+ return (
75
+ <span
76
+ /*
77
+ `h-7` matches the queue row exactly: both can be on screen at once, and
78
+ two heights read as two kinds of thing when they are the same kind —
79
+ something staged on this draft.
80
+ */
81
+ className={`inline-flex h-7 max-w-[12rem] items-center gap-1.5 ${className ?? ""}`}
82
+ // The id as data, so a host can style or find one pick without this
83
+ // package choosing a colour.
84
+ data-tool={pick.id}
85
+ >
86
+ {pick.icon === undefined ? null : icon?.(pick.icon)}
87
+ {/* `min-w-0` so the truncation resolves: a flex child refuses to shrink
88
+ past its content, which lets a long label widen the whole row. */}
89
+ <span className="min-w-0 truncate">{pick.label}</span>
90
+ <button
91
+ type="button"
92
+ /* `shrink-0` so the label truncates instead of this button vanishing —
93
+ a chip with no way to remove it is a dead end. Its reveal on hover
94
+ and focus lives in `styles/composer.css`, which owns the chip. */
95
+ className="shrink-0"
96
+ aria-label={copy.toolRemove}
97
+ ref={removeRef}
98
+ onClick={remove}
99
+ >
100
+ <X className="size-3" aria-hidden />
101
+ </button>
102
+ </span>
103
+ );
104
+ });
@@ -0,0 +1,202 @@
1
+ import { ArrowUp, SkipForward, X } from "lucide-react";
2
+ import { useState, type ReactNode } from "react";
3
+
4
+ import type { QueuedPrompt } from "../../core/queue";
5
+ import { plural, useChatCopy, useChatLanguage } from "../../i18n";
6
+ import { useChatEvents, useQueue, useTurn } from "../../store";
7
+ import { useReplaceText, type AskReplace } from "./use-replace-text";
8
+
9
+ /**
10
+ * Rows kept on screen. Everything past this folds behind one line.
11
+ *
12
+ * Three, because the composer sits at the bottom of the transcript and each row
13
+ * eats it upward — enough to see a queue is forming, few enough that a long one
14
+ * does not become the screen.
15
+ */
16
+ const INLINE_LIMIT = 3;
17
+
18
+ export interface ComposerQueueProps {
19
+ /**
20
+ * Asked before a prompt cuts the running turn short.
21
+ *
22
+ * Injected for the same reason `useCopyAction` takes `write`: a dialog
23
+ * imported here would give this package a dialog implementation, and a second
24
+ * host no way to replace it. Absent ⇒ no confirmation and no control, because
25
+ * an unconfirmed interrupt is the one shape this must never ship.
26
+ *
27
+ * Resolving false must leave the queue untouched.
28
+ */
29
+ readonly confirmSendNow?: () => Promise<boolean>;
30
+ /**
31
+ * Asked before taking a prompt back into a composer that already holds text.
32
+ *
33
+ * Absent ⇒ the take replaces the draft outright. Editing still works; what is
34
+ * missing is the chance to keep both.
35
+ */
36
+ readonly askReplace?: AskReplace;
37
+ readonly className?: string;
38
+ /** The row list. Where a host puts the gap between rows. */
39
+ readonly listClassName?: string;
40
+ readonly itemClassName?: string;
41
+ /** Render one queued prompt. Absent ⇒ its text, truncated by the host's CSS. */
42
+ readonly renderItem?: (prompt: QueuedPrompt) => ReactNode;
43
+ }
44
+
45
+ /**
46
+ * Prompts waiting for the current turn to finish.
47
+ *
48
+ * **Absent when empty, shown in full when short.** The queue is not a feature to
49
+ * manage — it is a reassurance that nothing was lost, and that reassurance is
50
+ * only delivered if the user can SEE what was taken. One or two rows is the
51
+ * ordinary case and costs a click to open when collapsed, so they are open.
52
+ *
53
+ * Past `INLINE_LIMIT` the strip collapses to the summary line: the composer sits
54
+ * at the bottom of the transcript and an unbounded list grows upward into it,
55
+ * the same way an unbounded attachment tray once put 51 chips on screen.
56
+ */
57
+ export function ComposerQueue({
58
+ confirmSendNow,
59
+ askReplace,
60
+ className,
61
+ listClassName,
62
+ itemClassName,
63
+ renderItem,
64
+ }: ComposerQueueProps) {
65
+ const { items, count, remove, take, sendNow } = useQueue();
66
+ const events = useChatEvents();
67
+ const replace = useReplaceText(askReplace);
68
+ const { isStreaming } = useTurn();
69
+ const copy = useChatCopy().queue;
70
+ const language = useChatLanguage();
71
+ const [expanded, setExpanded] = useState(false);
72
+
73
+ // Absent, not empty-and-styled: a zero-height strip still occupies a gap in
74
+ // the composer's grid and still reads as a landmark.
75
+ if (count === 0) return null;
76
+
77
+ // The OVERFLOW collapses, never the whole strip.
78
+ //
79
+ // Hiding every row past the limit replaced the queue with a bare count — the
80
+ // fifth prompt took the first four off screen, and the reassurance the strip
81
+ // exists to give ("nothing was lost, here it is") went with them. The rows
82
+ // the user can see are the feature; only the tail beyond what fits is worth
83
+ // trading for a line.
84
+ const overflows = count > INLINE_LIMIT;
85
+ const visible = expanded ? items : items.slice(0, INLINE_LIMIT);
86
+ const hidden = count - visible.length;
87
+
88
+ // Idle needs no confirmation, so it needs no host dialog either. Only the
89
+ // interrupting path is gated on `confirmSendNow` — an unconfirmed interrupt
90
+ // is the one shape this must never ship.
91
+ //
92
+ // Shown when idle, not only while a turn runs. The queue drains itself when a
93
+ // turn ENDS on its own, but Stop refuses to drain deliberately — so after a
94
+ // Stop the prompt sat with no release control at all, which reads as the
95
+ // message having been swallowed. That is the state that most needs it.
96
+ const canSendNow = !isStreaming || confirmSendNow !== undefined;
97
+ // The label states what the click COSTS, so it follows the same condition the
98
+ // dialog does rather than being fixed at the interrupting wording.
99
+ const sendNowLabel = isStreaming ? copy.sendNow : copy.send;
100
+
101
+ const onSendNow = (id: string) => {
102
+ // Only a RUNNING turn is lost, so only that case is worth a dialog. With
103
+ // nothing in flight this sends immediately: asking "this will interrupt the
104
+ // current answer" when there is no current answer describes nothing.
105
+ if (!isStreaming) {
106
+ sendNow(id);
107
+ return;
108
+ }
109
+ // The answer is awaited before anything moves — promoting first and asking
110
+ // after would reorder the queue for a user who then declines.
111
+ void confirmSendNow?.().then((ok) => {
112
+ if (ok) sendNow(id);
113
+ });
114
+ };
115
+
116
+ /**
117
+ * Take a queued prompt back for editing.
118
+ *
119
+ * The write is attempted FIRST and the queue entry removed only if it landed:
120
+ * taking first would drop the prompt when the user cancels the replace. The
121
+ * text is read from `items` rather than from `take`, because `take` is the
122
+ * removal and must not run before the answer.
123
+ */
124
+ const onEdit = (id: string, text: string) => {
125
+ void replace(text).then((written) => {
126
+ if (!written) return;
127
+ const prompt = items.find((item) => item.id === id);
128
+ take(id);
129
+ // Announced only once BOTH steps landed. This path and recall's Tab order
130
+ // them in opposite directions on purpose, so an emit that a listener
131
+ // acted on would invert one of them and lose the prompt on cancel.
132
+ if (prompt !== undefined) events.emit("queue:taken", { id, draft: prompt.draft });
133
+ });
134
+ };
135
+
136
+ const more = plural(copy.more, hidden, language);
137
+
138
+ return (
139
+ <div className={className} data-slot="composer-queue">
140
+ <ul className={listClassName}>
141
+ {visible.map((prompt) => (
142
+ <li key={prompt.id} className={itemClassName}>
143
+ {/* The text itself is the edit affordance — no third icon. Taking
144
+ a prompt back is what ↑ already does from the keyboard; this is
145
+ the same act for any row, with the mouse. */}
146
+ <button
147
+ type="button"
148
+ data-action="edit"
149
+ title={copy.edit}
150
+ onClick={() => onEdit(prompt.id, prompt.draft.text)}
151
+ >
152
+ {renderItem?.(prompt) ?? <span>{prompt.draft.text}</span>}
153
+ </button>
154
+ {/* Icon-only: the label is a sentence and rendering it took the
155
+ row from the prompt. It stays as the accessible name. */}
156
+ {canSendNow ? (
157
+ <button
158
+ type="button"
159
+ data-action="send-now"
160
+ aria-label={sendNowLabel}
161
+ title={sendNowLabel}
162
+ onClick={() => onSendNow(prompt.id)}
163
+ >
164
+ {/* Skip while a turn runs — it CUTS that turn, and a send
165
+ arrow would hide the cost. With nothing to cut it is an
166
+ ordinary send, and skip would name a turn that is over. */}
167
+ {isStreaming ? (
168
+ <SkipForward aria-hidden className="size-3.5" />
169
+ ) : (
170
+ <ArrowUp aria-hidden className="size-3.5" />
171
+ )}
172
+ </button>
173
+ ) : null}
174
+ <button
175
+ type="button"
176
+ aria-label={copy.remove}
177
+ title={copy.remove}
178
+ onClick={() => remove(prompt.id)}
179
+ >
180
+ <X aria-hidden className="size-3.5" />
181
+ </button>
182
+ </li>
183
+ ))}
184
+ </ul>
185
+ {/* One toggle, two labels: `hidden` is 0 once expanded, so the same
186
+ control reads "3 more" and then "Show less". `type="button"` because
187
+ inside the composer's <form> a bare button submits — expanding the
188
+ queue would send the turn. */}
189
+ {overflows ? (
190
+ <button
191
+ type="button"
192
+ data-action="toggle-overflow"
193
+ aria-expanded={expanded}
194
+ onClick={() => setExpanded((value) => !value)}
195
+ className="w-full text-left"
196
+ >
197
+ {expanded ? copy.showLess : more}
198
+ </button>
199
+ ) : null}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,58 @@
1
+ import { useCallback } from "react";
2
+
3
+ import { needsChoice } from "../../core/text-replace";
4
+ import { useChatSurfaces } from "../../store";
5
+ import { useComposer } from "../context";
6
+
7
+ /**
8
+ * Asked when the field already holds something the write would destroy.
9
+ *
10
+ * Injected like `confirmSendNow`: a dialog imported here would give the package
11
+ * a dialog implementation and leave a second host no way to replace it.
12
+ *
13
+ * `true` ⇒ replace. A "no" costs nothing: the caller holds its own side of the
14
+ * move until this resolves, so declining leaves the field AND the source as
15
+ * they were.
16
+ */
17
+ export type AskReplace = (incoming: string) => Promise<boolean>;
18
+
19
+ /**
20
+ * Write text into the composer without silently destroying a draft.
21
+ *
22
+ * Resolves to whether the write happened, so a caller that also MOVES something
23
+ * — taking a queued prompt out of the queue — can hold its own side until the
24
+ * answer is in. The other order loses the prompt when the user cancels.
25
+ *
26
+ * Absent `ask` ⇒ replace outright. That is the honest default for a host with no
27
+ * dialog; refusing the write instead would read as a dead control.
28
+ */
29
+ export function useReplaceText(ask?: AskReplace): (incoming: string) => Promise<boolean> {
30
+ const { draft, setText } = useComposer();
31
+ // The registry already knows which form is this chat's composer, and already
32
+ // retries on the next frame when it has not mounted. Locating the field here
33
+ // instead would be a second answer to the same question — the failure
34
+ // `surfaces.ts` was written to end.
35
+ const surfaces = useChatSurfaces();
36
+
37
+ return useCallback(
38
+ async (incoming: string) => {
39
+ if (incoming === "") return false;
40
+
41
+ const write = (text: string) => {
42
+ setText(text);
43
+ // Deferred: React writes the value on the next commit, so focusing now
44
+ // would put the caret against the OLD text and leave it mid-string.
45
+ queueMicrotask(() => surfaces?.focusComposer());
46
+ };
47
+
48
+ if (!needsChoice(draft.text, incoming) || ask === undefined) {
49
+ write(incoming);
50
+ return true;
51
+ }
52
+ if (!(await ask(incoming))) return false;
53
+ write(incoming);
54
+ return true;
55
+ },
56
+ [draft.text, setText, surfaces, ask],
57
+ );
58
+ }
@@ -0,0 +1,79 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import { draftStorageKey, draftWrite, mayPersist, restoredDraft } from "../core/draft-storage";
4
+ import { useComposer } from "./context";
5
+
6
+ /**
7
+ * Keep an unsent draft across a reload, per room.
8
+ *
9
+ * `sessionStorage`, not `localStorage`: a draft should survive an accidental
10
+ * reload, and should NOT still be waiting tomorrow in a room the user has left.
11
+ * Prompt recall makes the opposite trade, hence the different store.
12
+ *
13
+ * TEXT ONLY — a staged file is an object-URL pinned to this document, so a
14
+ * restored one is a chip that renders and cannot send. The ROOM is the
15
+ * caller's; this package has no notion of one (`use-reload-on-revision.ts`
16
+ * draws the same line).
17
+ *
18
+ * The rules live in `core/draft-storage.ts` so they can be tested without a
19
+ * DOM; this owns only the effects and refs.
20
+ */
21
+
22
+ function read(key: string): string | null {
23
+ // Guarded: a sandboxed iframe and an SSR pass both THROW here rather than
24
+ // returning null, and a composer that cannot mount is worse than one that
25
+ // forgets.
26
+ try {
27
+ return globalThis.sessionStorage?.getItem(key) ?? null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function write(key: string, text: string): void {
34
+ const decision = draftWrite(text);
35
+ try {
36
+ if ("remove" in decision) globalThis.sessionStorage?.removeItem(key);
37
+ else globalThis.sessionStorage?.setItem(key, decision.value);
38
+ } catch {
39
+ // Quota, or storage denied. The draft still applies to this session; failing
40
+ // the keystroke because it cannot be saved would be the worse outcome.
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Restore on mount, save on change.
46
+ *
47
+ * Clearing after a send needs no branch of its own: `send` resets the draft to
48
+ * `emptyDraft`, so the save path writes `""`, which removes the entry. Adding a
49
+ * send-specific hook here would give one behaviour two triggers.
50
+ *
51
+ * `room` changing means a different conversation: the effect restores that
52
+ * room's draft rather than carrying the current text into it.
53
+ */
54
+ export function useDraftPersistence(room: string | undefined): void {
55
+ const { draft, setText } = useComposer();
56
+ // Which room's restore has completed. The save effect stays inert until it
57
+ // matches, so a restore is never overwritten by the emptiness preceding it.
58
+ const restored = useRef<string | null>(null);
59
+ // Read through a ref: seeding the restore effect with `setText` would re-arm
60
+ // it whenever the draft identity changes, re-restoring mid-typing.
61
+ const setTextRef = useRef(setText);
62
+ setTextRef.current = setText;
63
+
64
+ useEffect(() => {
65
+ // Absent room ⇒ the host wants no persistence. Guarded inside the effect,
66
+ // never by skipping the hook: a conditional call changes hook order the
67
+ // first time a host makes the room conditional.
68
+ if (room === undefined) return;
69
+ const stored = restoredDraft(read(draftStorageKey(room)));
70
+ restored.current = room;
71
+ if (stored !== null) setTextRef.current(stored);
72
+ }, [room]);
73
+
74
+ useEffect(() => {
75
+ if (room === undefined) return;
76
+ if (!mayPersist(restored.current, room)) return;
77
+ write(draftStorageKey(room), draft.text);
78
+ }, [room, draft.text]);
79
+ }
@@ -0,0 +1,16 @@
1
+ // Markdown, code and math — the string-being-streamed layer.
2
+ //
3
+ // Separate from widgets/ because the input is a STRING ARRIVING TOKEN BY TOKEN,
4
+ // not a finished data object. That difference is the whole problem: a
5
+ // half-arrived fence must not throw, and a completed message must not re-parse
6
+ // because a sibling changed.
7
+ //
8
+ // One component. The folder is small on purpose — the parser is `streamdown`,
9
+ // adopted for exactly one property (it parses incomplete markdown) and pinned
10
+ // by tests that would fail if it were swapped back. What lives here is the
11
+ // CONFIGURATION of that parser, which is the part that is ours.
12
+ //
13
+ // May import: core, utils.
14
+
15
+ export { Response, type ResponseProps } from "./response";
16
+ export { renderMediaContent } from "./media-content";
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { LazyDiagram } from "@djangocfg/widget-media/diagram";
4
+ import type { MediaBlock } from "../core";
5
+
6
+ /**
7
+ * The renderer for the media kinds whose payload is TEXT, not a URL.
8
+ *
9
+ * `MediaWidget` has always routed `json`/`code`/`markdown`/`text`/`mermaid`
10
+ * through an optional `renderContent`, and nothing ever supplied one — so every
11
+ * one of them fell through to a `<pre>`, including the diagrams the `show_media`
12
+ * tool declares among its kinds. The callback was the seam; this is the thing
13
+ * that was missing from the other side of it.
14
+ *
15
+ * Only `mermaid` is answered here. The other four ARE plain text until this
16
+ * package has a reason to say otherwise, and the `<pre>` fallback is a correct
17
+ * rendering of them rather than a placeholder — returning null for those is what
18
+ * keeps the widget's own fallback in charge instead of duplicating it.
19
+ *
20
+ * Lives in `content/` because that is the layer that owns rendered content, and
21
+ * `widgets/` may import it. The diagram reaches the transcript through the
22
+ * layer whose job it is, not through a package a widget imported directly.
23
+ */
24
+ export function renderMediaContent(block: MediaBlock): ReactNode {
25
+ if (block.kind !== "mermaid") return null;
26
+ // The wire calls this field `chart`; `readMediaBlock` normalises every
27
+ // textual kind onto `content`, so there is one name here for all of them.
28
+ const chart = block.content;
29
+ if (chart === undefined || chart.trim() === "") return null;
30
+ // Never streaming: a `show_media` block is dispatched once, whole. That is
31
+ // the caller-side half of the guard the renderer cannot answer from the
32
+ // source alone — the fenced rail, where the text really does arrive a token
33
+ // at a time, is guarded inside the streamdown plugin instead.
34
+ // Lazy: this file is reachable from the package ROOT through `data.tsx`, so
35
+ // an eager import would put the parser in every transcript's first paint —
36
+ // including the ones that never show a diagram.
37
+ return <LazyDiagram chart={chart} />;
38
+ }