@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,81 @@
1
+ import type React from "react";
2
+
3
+ import type { ChatMessage } from "../core/parts";
4
+ import { useMessageIds } from "../store";
5
+ import { CollapseTrigger, CollapsibleBody, useCollapse } from "./collapsible-body";
6
+ import { ChatMessageFrame } from "./message";
7
+
8
+ /**
9
+ * The row that owns its own collapse.
10
+ *
11
+ * Its own component because `useCollapse` is a hook and `ChatMessageView`'s
12
+ * render callback is not a component — and because the body and the toolbar are siblings
13
+ * inside the frame, so their shared state must live above both.
14
+ *
15
+ * Composed here rather than inside the frame, which subagent threads and the
16
+ * channel rows also use: a turn is bounded because it is long, and that is a
17
+ * fact about a TRANSCRIPT row.
18
+ */
19
+ export function CollapsibleRow({
20
+ message,
21
+ parts,
22
+ className,
23
+ footer,
24
+ }: {
25
+ readonly message: ChatMessage;
26
+ readonly parts: React.ReactNode;
27
+ readonly className?: string;
28
+ readonly footer?: React.ReactNode;
29
+ }) {
30
+ // `parts` is the revision — it changes as a reply streams, which is what
31
+ // re-measures the height.
32
+ const collapse = useCollapse(
33
+ message.status.state === "streaming",
34
+ parts,
35
+ useMayCollapse(message.id),
36
+ );
37
+ return (
38
+ <ChatMessageFrame
39
+ message={message}
40
+ className={className}
41
+ // Both controls act on THIS turn, so they belong at the same end, next to
42
+ // each other. `footerEnd` is the opposite corner — filling it makes the
43
+ // frame justify the row apart, which threw the trigger 700px across the
44
+ // transcript to the far edge and left it looking absent beside the reply
45
+ // it opens.
46
+ footer={
47
+ // undefined when there is nothing to show: the frame keys the toolbar's
48
+ // very existence on this prop, and a fragment is never undefined — so
49
+ // building one unconditionally would reserve a 28px row under EVERY
50
+ // turn, including the short ones that have no controls at all.
51
+ footer === undefined && !collapse.showTrigger ? undefined : (
52
+ <>
53
+ {footer}
54
+ <CollapseTrigger collapse={collapse} />
55
+ </>
56
+ )
57
+ }
58
+ >
59
+ <CollapsibleBody collapse={collapse}>{parts}</CollapsibleBody>
60
+ </ChatMessageFrame>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Whether a turn is eligible to be collapsed at all.
66
+ *
67
+ * The newest turn is not: there is nothing below it to scroll past, so the
68
+ * height it saves buys the reader nothing, and folding the answer they are
69
+ * reading right now is the opposite of the point.
70
+ *
71
+ * Read at MOUNT only — `useCollapse` freezes it — so a turn that was last when
72
+ * it arrived stays open after the next message pushes it up. That is what keeps
73
+ * a positional rule from retroactively folding something mid-read.
74
+ *
75
+ * `ids` is reference-stable unless a message is added, so this subscription is
76
+ * as cheap as the transcript's own.
77
+ */
78
+ function useMayCollapse(id: string): boolean {
79
+ const ids = useMessageIds();
80
+ return ids[ids.length - 1] !== id;
81
+ }
@@ -0,0 +1,49 @@
1
+ export interface DayDividerProps {
2
+ /** The day this heading opens, already resolved to the reader's wording. */
3
+ readonly label: string;
4
+ /** Machine-readable date (`YYYY-MM-DD`) for the `<time>` element. */
5
+ readonly dateTime: string;
6
+ }
7
+
8
+ /**
9
+ * The day heading between two turns.
10
+ *
11
+ * A `<time>` inside a separator, not a bare line of text: the label may read
12
+ * "Today", which names no date to anyone arriving at the transcript later —
13
+ * `dateTime` keeps the fact machine-readable however the copy is worded.
14
+ *
15
+ * `role="separator"` rather than `<hr>`: the element carries a visible label,
16
+ * and an `<hr>` with text inside is not a thing. The role announces the break
17
+ * and `aria-label` gives it the same words a sighted reader sees.
18
+ *
19
+ * Sticky, so the day a reader is inside stays named while they scroll through
20
+ * it. `top-0` against the scroller, which is the transcript's own element.
21
+ */
22
+ export function DayDivider({ label, dateTime }: DayDividerProps) {
23
+ return (
24
+ <div
25
+ role="separator"
26
+ aria-label={label}
27
+ // Asymmetric on purpose: the heading belongs to the day BELOW it, so it
28
+ // sits closer to the turn it opens than to the one it ends. The generous
29
+ // top margin is what separates it from the previous message's action row,
30
+ // which reveals on hover — matching `py-3` there put a hidden control and
31
+ // a heading on the same line the moment a pointer arrived.
32
+ className="sticky top-0 z-10 mt-8 flex items-center gap-3 pt-2 pb-4"
33
+ >
34
+ <span className="bg-border h-px flex-1" aria-hidden />
35
+ {/*
36
+ The pill sits ON the line, so it needs its own ground or the rule runs
37
+ through the text. `bg-background` is the scroller's, which is what the
38
+ transcript paints.
39
+ */}
40
+ <time
41
+ dateTime={dateTime}
42
+ className="bg-background text-muted-foreground rounded-full px-2 text-xs"
43
+ >
44
+ {label}
45
+ </time>
46
+ <span className="bg-border h-px flex-1" aria-hidden />
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,54 @@
1
+ // The styled layer — what a host actually mounts.
2
+ //
3
+ // This is the ONLY folder that knows ui-core and semantic tokens. Keeping it
4
+ // separate is what makes the copy-in strategy possible at all: components
5
+ // arrive from @sources as FORM, get re-tokened here, and never reach the
6
+ // layers below.
7
+ //
8
+ // May import: everything below it.
9
+ // May NOT be imported by: anything in this package.
10
+ //
11
+ // shadcn components are substituted with ui-core equivalents; four of those
12
+ // substitutions are not mechanical.
13
+
14
+ export { Chat } from "./chat";
15
+ export type { ChatProps, ChatBody, ChatConversation, ChatSlots } from "./chat";
16
+ export { ChatComposer } from "./chat-composer";
17
+ export type { ChatComposerProps } from "./chat-composer";
18
+ export { ChatTranscript } from "./chat-transcript";
19
+ export type { ChatTranscriptProps } from "./chat-transcript";
20
+ export { ChatSuggestions } from "./chat-suggestions";
21
+ export type { ChatSuggestionsProps } from "./chat-suggestions";
22
+ export { ChatMessageView, ChatMessageFrame, MessageProse } from "./message";
23
+ export { CollapsibleBody } from "./collapsible-body";
24
+ export { JumpToLatest } from "./jump-to-latest";
25
+ export type { JumpToLatestProps } from "./jump-to-latest";
26
+ export { MessageActions, messageCopyText, useCopyAction } from "./message-actions";
27
+ export type {
28
+ MessageAction,
29
+ MessageActionsProps,
30
+ UseCopyActionOptions,
31
+ } from "./message-actions";
32
+ // Part renderers a host registers by name. Not a default registry: a host still
33
+ // names them, because which part types it draws — and what it substitutes for
34
+ // one — is its decision. There is no text renderer here on purpose; drawing
35
+ // text needs a markdown component, and `./content` is a separate subpath so a
36
+ // read-only host pays nothing for shiki and katex.
37
+ //
38
+ // `*Renderer`, never `*Part`: `core` already exports `DataPart`, `ToolPart` and
39
+ // `ReasoningPart` as the part TYPES, and the root barrel re-exports both layers,
40
+ // so matching names would make every one of them ambiguous.
41
+ export { DataRenderer } from "./renderers/data";
42
+ export { ReasoningRenderer } from "./renderers/reasoning";
43
+ export { ToolRenderer } from "./renderers/tool";
44
+ export { PendingIndicator } from "./pending";
45
+ export type { PendingIndicatorProps } from "./pending";
46
+ export { DayDivider } from "./day-divider";
47
+ export type { DayDividerProps } from "./day-divider";
48
+ export { TurnMark } from "./turn-mark";
49
+ export type { TurnMarkProps, TurnMarkState } from "./turn-mark";
50
+ export type { ChatMessageViewProps, ChatMessageFrameProps } from "./message";
51
+ export { CollapseTrigger, useCollapse } from "./collapsible-body";
52
+ export type { Collapse, CollapseState } from "./collapsible-body";
53
+ export { LinkChip } from "./link-chip";
54
+ export type { LinkChipProps } from "./link-chip";
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "@djangocfg/ui-core/lib";
4
+
5
+ export interface JumpToLatestProps {
6
+ readonly onClick: () => void;
7
+ /** Accessible name. The button is icon-only, so nothing else names it. */
8
+ readonly label: string;
9
+ /**
10
+ * Replies that arrived since the reader left, or null when there is nothing
11
+ * honest to show. Null renders no badge — an empty one claims a count.
12
+ */
13
+ readonly missed?: number | null;
14
+ readonly icon: ReactNode;
15
+ readonly className?: string;
16
+ }
17
+
18
+ /**
19
+ * The return-to-bottom control.
20
+ *
21
+ * An overlay, unlike the message toolbar: it belongs to the viewport rather
22
+ * than to any message, so it must not sit in the transcript's flow and scroll
23
+ * away with the content it is offering to leave.
24
+ *
25
+ * Rendering is gated by `Transcript`, which shows this only while unpinned —
26
+ * this component must not decide its own visibility, or two owners would
27
+ * disagree about where the viewport is.
28
+ */
29
+ export function JumpToLatest({ onClick, label, missed, icon, className }: JumpToLatestProps) {
30
+ const hasBadge = missed !== null && missed !== undefined && missed > 0;
31
+ // A pill when it carries a count, a disc when it does not — the shape says
32
+ // whether there is something to come back TO before the number is read.
33
+ const shape = hasBadge ? "h-8 ps-2 pe-3" : "size-8 justify-center";
34
+ const count = hasBadge ? (
35
+ <span className="text-xs font-medium tabular-nums">{missed}</span>
36
+ ) : null;
37
+ const classes = cn(
38
+ // Anchored to the zero-height strip Transcript places at the scroller's
39
+ // lower edge, so it floats just above the last message and clear of the
40
+ // composer below.
41
+ "absolute bottom-2 left-1/2 z-20 -translate-x-1/2",
42
+ "inline-flex items-center gap-1.5 rounded-full",
43
+ // Blurred backdrop rather than an opaque fill: the control sits over live
44
+ // text, and a solid disc reads as a hole punched in the reply.
45
+ "bg-background/70 shadow-md backdrop-blur-md",
46
+ "border border-border",
47
+ shape,
48
+ "text-muted-foreground transition-colors",
49
+ "hover:bg-secondary hover:text-foreground",
50
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
51
+ className,
52
+ );
53
+
54
+ return (
55
+ <button type="button" onClick={onClick} aria-label={label} title={label} className={classes}>
56
+ {icon}
57
+ {count}
58
+ </button>
59
+ );
60
+ }
@@ -0,0 +1,73 @@
1
+ import type { ComponentProps } from "react";
2
+ import { Globe } from "lucide-react";
3
+
4
+ import { linkChipLabel } from "../core/links";
5
+
6
+ /**
7
+ * The anchor's own props are accepted and forwarded so a wrapper that CLONES
8
+ * this element — a hover card trigger, a tooltip — can attach its handlers.
9
+ */
10
+ export interface LinkChipProps extends ComponentProps<"a"> {
11
+ /** The href, already hardened by the renderer. */
12
+ href: string;
13
+ }
14
+
15
+ /**
16
+ * Inline chip rendered in place of a BARE url in a chat bubble.
17
+ *
18
+ * ## Why only a bare url
19
+ *
20
+ * `[the docs](https://…)` already has the label its author chose; boxing it
21
+ * adds weight and removes nothing. A bare url has no label at all, so the
22
+ * reader is left parsing the raw string — often longer than the sentence
23
+ * around it, and wrapping mid-url across two lines. That is what a chip fixes.
24
+ * The decision itself lives in `core/links/prose.ts`, so it is testable
25
+ * without a DOM.
26
+ *
27
+ * ## An anchor, not a button
28
+ *
29
+ * A link is a LINK: the browser already knows how to open one in a new tab,
30
+ * copy its address, or show it on hover, and every one of those is lost the
31
+ * moment a button stands in for it.
32
+ *
33
+ * ## The label is the short form, the href is the tooltip
34
+ *
35
+ * Same trade the file chip makes with a path. `linkChipLabel` keeps the domain
36
+ * whole and collapses the path's middle; the whole url stays one hover away.
37
+ *
38
+ * ## It does not take which side of the bubble it is on
39
+ *
40
+ * It used to, and nothing could supply the answer: the anchor is rendered from
41
+ * the host's memoized `components` object, which is built once per screen and
42
+ * cannot carry a per-message value without re-parsing every message. The
43
+ * stylesheet reads `data-role` off the message wrapper instead.
44
+ */
45
+ export function LinkChip({ href, ...rest }: LinkChipProps) {
46
+ const label = linkChipLabel(href);
47
+
48
+ return (
49
+ <a
50
+ // FIRST, so the hardened `target`/`rel` below cannot be overwritten by a
51
+ // caller — but present at all so a cloning wrapper's handlers survive.
52
+ {...rest}
53
+ href={href}
54
+ // Size, colour, ground and the width ceiling come from `.chat-chip` and
55
+ // `.chat-link-chip` in markdown.css. This list carries STRUCTURE only.
56
+ //
57
+ // No `items-center`: the rule sets `align-items: baseline` so the LABEL
58
+ // donates the chip's baseline, and a utility would win over it.
59
+ className="chat-chip chat-link-chip inline-flex h-auto min-w-0 gap-1 align-baseline whitespace-nowrap"
60
+ // The label collapses the url's middle, so the whole address is visible
61
+ // NOWHERE else — this is both the tooltip and the accessible name. A
62
+ // wrapper showing it more richly passes `title=""` to suppress it.
63
+ title={rest.title ?? href}
64
+ target="_blank"
65
+ // `noreferrer` implies `noopener`; both are stated because the pair is
66
+ // what keeps the opened page from reaching back through `window.opener`.
67
+ rel="noreferrer noopener"
68
+ >
69
+ <Globe aria-hidden />
70
+ <span className="truncate">{label}</span>
71
+ </a>
72
+ );
73
+ }
@@ -0,0 +1,163 @@
1
+ import { useCallback, useState, type ReactNode } from "react";
2
+
3
+ import { cn } from "@djangocfg/ui-core/lib";
4
+
5
+ import { isTextPart, type ChatMessage } from "../core/parts";
6
+ import { ChatTooltip } from "../primitives/tooltip";
7
+
8
+ /**
9
+ * The per-message action row: the package owns placement, reveal and a11y; the
10
+ * host owns which actions exist. Hence a list of descriptors, not `showRetry` /
11
+ * `showCopy` flags — a flag per feature cannot express an action this package
12
+ * has never heard of, and a host with no retry lane gets a correct one-button
13
+ * row instead of a broken three-button one.
14
+ */
15
+
16
+ export interface MessageAction {
17
+ /** Stable key. Also the `data-action` value, which is what a story asserts on. */
18
+ readonly id: string;
19
+ /** Accessible name. Required — the row is icon-only, so nothing else names it. */
20
+ readonly label: string;
21
+ readonly icon: ReactNode;
22
+ readonly onSelect: () => void;
23
+ readonly disabled?: boolean;
24
+ }
25
+
26
+ export interface MessageActionsProps {
27
+ readonly message: ChatMessage;
28
+ readonly actions: readonly MessageAction[];
29
+ readonly className?: string;
30
+ }
31
+
32
+ /**
33
+ * In flow and always present, faint until the message is hovered or focused.
34
+ *
35
+ * Not an overlay: a row that floats over the transcript does not read as
36
+ * belonging to the message it acts on. It holds its place instead, and the
37
+ * height it takes is given back by pulling the NEXT message up (`ChatMessage-
38
+ * Frame` sets a negative top margin on the following row), so the transcript
39
+ * keeps the spacing it had before actions existed.
40
+ *
41
+ * Faint rather than invisible: at zero the control cannot be discovered without
42
+ * hovering every message, and a first-time reader never learns it is there.
43
+ * `focus-within` is the keyboard half — hover alone leaves it unreachable.
44
+ */
45
+ export function MessageActions({ message, actions, className }: MessageActionsProps) {
46
+ const isUser = message.role === "user";
47
+ if (actions.length === 0) return null;
48
+
49
+ return (
50
+ <div
51
+ role="group"
52
+ aria-label={message.role}
53
+ className={cn(
54
+ "flex h-7 items-center gap-0.5",
55
+ // Aligned with what it acts on: under the reply on the left, under the
56
+ // bubble on the right.
57
+ isUser ? "justify-end" : "justify-start",
58
+ "opacity-10 transition-opacity",
59
+ "group-hover:opacity-100 group-focus-within:opacity-100",
60
+ className,
61
+ )}
62
+ >
63
+ {actions.map((action) => (
64
+ // The design system's tooltip, not the native `title`: the OS bubble
65
+ // ignores the theme, waits ~1s, and would be the one control in the
66
+ // host still using it. `label` doubles as the `aria-label`, which
67
+ // Radix links with `aria-describedby` rather than repeating.
68
+ //
69
+ // A DISABLED button fires no pointer events, so its tooltip never
70
+ // opens — the same known limit the composer's Send carries. The name
71
+ // stays reachable through `aria-label`.
72
+ <ChatTooltip key={action.id} label={action.label}>
73
+ <button
74
+ type="button"
75
+ data-action={action.id}
76
+ aria-label={action.label}
77
+ disabled={action.disabled}
78
+ onClick={action.onSelect}
79
+ className={cn(
80
+ "inline-flex h-7 w-7 items-center justify-center rounded-md",
81
+ "text-muted-foreground transition-colors",
82
+ "hover:bg-secondary hover:text-foreground",
83
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
84
+ "disabled:pointer-events-none disabled:opacity-50",
85
+ )}
86
+ >
87
+ {action.icon}
88
+ </button>
89
+ </ChatTooltip>
90
+ ))}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Text parts only. Reasoning collapses by default and is not the answer a
97
+ * reader means by "copy this"; a tool call's rendered form is not its data, so
98
+ * copying a diff card as JSON is worse than offering nothing.
99
+ */
100
+ export function messageCopyText(message: ChatMessage): string {
101
+ return message.parts
102
+ .filter(isTextPart)
103
+ .map((part) => part.text)
104
+ .join("\n\n")
105
+ .trim();
106
+ }
107
+
108
+ export interface UseCopyActionOptions {
109
+ readonly label: string;
110
+ readonly copiedLabel: string;
111
+ readonly icon: ReactNode;
112
+ readonly copiedIcon: ReactNode;
113
+ /**
114
+ * Injected so this package acquires no clipboard implementation. Must return
115
+ * false rather than throw when the write did not land — the confirmed state
116
+ * reads it.
117
+ */
118
+ readonly write: (text: string) => Promise<boolean>;
119
+ /** How long the confirmed state holds, in ms. */
120
+ readonly confirmMs?: number;
121
+ }
122
+
123
+ /**
124
+ * `undefined` when the turn has no text, so a tool-only turn offers no control
125
+ * rather than one that writes an empty string. The icon flips only on a real
126
+ * success — a check after a failed write is a lie the user acts on.
127
+ *
128
+ * Also `undefined` while the turn is still STREAMING. Text length alone was the
129
+ * whole condition, and a streaming turn has text from its first token, so the
130
+ * control appeared over a half-written answer and copied whatever had arrived
131
+ * by the instant of the click. Worse, it appeared over a turn showing only
132
+ * "Thinking" — a reasoning part carries no text, so the row was offering to
133
+ * copy nothing at all.
134
+ *
135
+ * `interrupted` and `failed` DO get the control: both are final, their text is
136
+ * whatever the turn managed to produce, and that is exactly what a reader wants
137
+ * to keep. Only `streaming` is a moving target.
138
+ */
139
+ export function useCopyAction(
140
+ message: ChatMessage,
141
+ options: UseCopyActionOptions,
142
+ ): MessageAction | undefined {
143
+ const { label, copiedLabel, icon, copiedIcon, write, confirmMs = 1500 } = options;
144
+ const [copied, setCopied] = useState(false);
145
+ const text = messageCopyText(message);
146
+
147
+ const onSelect = useCallback(() => {
148
+ void write(text).then((ok) => {
149
+ if (!ok) return;
150
+ setCopied(true);
151
+ setTimeout(() => setCopied(false), confirmMs);
152
+ });
153
+ }, [write, text, confirmMs]);
154
+
155
+ if (text.length === 0) return undefined;
156
+ if (message.status.state === "streaming") return undefined;
157
+ return {
158
+ id: "copy",
159
+ label: copied ? copiedLabel : label,
160
+ icon: copied ? copiedIcon : icon,
161
+ onSelect,
162
+ };
163
+ }
@@ -0,0 +1,47 @@
1
+ import type { ChatMessage } from "../core/parts";
2
+ import { messageCopyText } from "./message-actions";
3
+ import { useChatCopy } from "../i18n";
4
+ import { useChatSurfaces, useMessage } from "../store";
5
+
6
+ /**
7
+ * The message a turn answers, above its own text.
8
+ *
9
+ * Renders nothing when this turn answers nothing, or when the answered message
10
+ * is not in the loaded transcript — a quote naming a row the reader cannot
11
+ * scroll to would describe something that is not there. History is paged, so
12
+ * that is an ordinary state rather than an error.
13
+ *
14
+ * Resolved from the store, like the composer's chip: one index, so the quote a
15
+ * sender saw and the quote a reader sees cannot disagree.
16
+ */
17
+ export function MessageQuote({ message }: { readonly message: ChatMessage }) {
18
+ const target = useMessage(message.replyTo ?? "");
19
+ const surfaces = useChatSurfaces();
20
+ const copy = useChatCopy().message;
21
+ if (message.replyTo === undefined || target === undefined) return null;
22
+
23
+ const quoted = message.replyTo;
24
+ return (
25
+ <button
26
+ type="button"
27
+ onClick={() => surfaces?.revealMessage(quoted)}
28
+ aria-label={copy.jumpToQuoted}
29
+ // `w-fit` with a cap, not `w-full`: the quote belongs to ONE bubble, and a
30
+ // full-column bar above a short right-aligned turn reads as a divider for
31
+ // the whole transcript. The cap matches the user bubble's own measure so a
32
+ // long quote wraps where the bubble would.
33
+ //
34
+ // `ms-auto` on a user turn puts it over the bubble it belongs to. The row
35
+ // below is aligned by the frame's own `items-end`, which this element sits
36
+ // outside of, so it places itself.
37
+ className={`chat-quote hover:bg-muted/50 mb-1 flex w-fit max-w-[52ch] items-center gap-2 rounded px-1 py-0.5 text-left text-xs${
38
+ message.role === "user" ? " ms-auto" : ""
39
+ }`}
40
+ >
41
+ <span className="bg-primary/60 h-4 w-0.5 shrink-0 rounded-full" aria-hidden />
42
+ <span className="text-muted-foreground min-w-0 truncate">
43
+ {messageCopyText(target)}
44
+ </span>
45
+ </button>
46
+ );
47
+ }