@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,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
|
+
}
|