@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,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
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -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
|
+
}
|