@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prompt ladder: what ArrowUp and ArrowDown land on, as pure state.
|
|
3
|
+
*
|
|
4
|
+
* `boundary.ts` answers "may this press navigate"; this answers "to what". Split
|
|
5
|
+
* because they fail differently — a boundary bug moves the caret when it should
|
|
6
|
+
* not, a ladder bug loses the user's draft — and because the boundary rules were
|
|
7
|
+
* ported verbatim while this is where the queue rung is new.
|
|
8
|
+
*
|
|
9
|
+
* ## The ladder, from the bottom
|
|
10
|
+
*
|
|
11
|
+
* draft ← what the user was typing, stashed on the first ArrowUp
|
|
12
|
+
* queued (last) ← the newest prompt waiting to be sent, if any
|
|
13
|
+
* sent[n-1] ← the most recent sent prompt
|
|
14
|
+
* …
|
|
15
|
+
* sent[0] ← the oldest kept
|
|
16
|
+
*
|
|
17
|
+
* ArrowUp climbs, ArrowDown descends, and the bottom is always the draft the
|
|
18
|
+
* user had before they started walking. Losing that draft is the failure this
|
|
19
|
+
* design exists to prevent: a user presses Up once out of curiosity and their
|
|
20
|
+
* half-written message must come back on Down.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the queue is a rung, and only its LAST entry
|
|
23
|
+
*
|
|
24
|
+
* A queued prompt was written but not answered, so it is the nearest thing in
|
|
25
|
+
* time to the draft — closer than anything already sent. Only the last one is a
|
|
26
|
+
* rung: it is the one a user reaching backwards means, and it is the one they
|
|
27
|
+
* would edit. Walking the whole queue would put entries the user can already
|
|
28
|
+
* see, and remove, into a ladder they cannot see.
|
|
29
|
+
*
|
|
30
|
+
* ## Why `index` is not enough on its own
|
|
31
|
+
*
|
|
32
|
+
* The pending rung cannot be an index into `sent` — it sits between the draft
|
|
33
|
+
* and `sent[n-1]` and belongs to neither array. Encoding it as `index === -1`
|
|
34
|
+
* would make it indistinguishable from "not walking at all", and a second
|
|
35
|
+
* ArrowDown would then jump to the draft, skipping every entry just walked.
|
|
36
|
+
* Hence an explicit position type.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Where the ladder currently is. `draft` is the bottom and the resting state. */
|
|
40
|
+
export type RecallPosition =
|
|
41
|
+
| { readonly kind: "draft" }
|
|
42
|
+
| { readonly kind: "queued" }
|
|
43
|
+
| { readonly kind: "sent"; readonly index: number };
|
|
44
|
+
|
|
45
|
+
export interface RecallState {
|
|
46
|
+
readonly position: RecallPosition;
|
|
47
|
+
/**
|
|
48
|
+
* The user's own text, held while they walk.
|
|
49
|
+
*
|
|
50
|
+
* `null` means nothing is stashed — i.e. the ladder is at rest. Stashed ONCE,
|
|
51
|
+
* on the first step off the draft, never on subsequent steps: re-stashing
|
|
52
|
+
* would overwrite the user's text with a recalled entry and the draft would be
|
|
53
|
+
* gone with no way back.
|
|
54
|
+
*/
|
|
55
|
+
readonly stashedDraft: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* What recall last wrote into the buffer.
|
|
58
|
+
*
|
|
59
|
+
* `bufferIsRecallOwned` reads this to tell "the user typed this" from "recall
|
|
60
|
+
* put this here". Cleared when the ladder returns to rest, so a stale value
|
|
61
|
+
* cannot make a fresh draft look recall-owned.
|
|
62
|
+
*/
|
|
63
|
+
readonly lastRecalled: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RecallLadder {
|
|
67
|
+
/** Sent prompts, OLDEST first — the order ArrowUp walks backwards through. */
|
|
68
|
+
readonly sent: readonly string[];
|
|
69
|
+
/** Prompts queued behind an in-flight turn, oldest first. Only the last is a rung. */
|
|
70
|
+
readonly queued: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A move's outcome: the new state, and the text to put in the buffer. */
|
|
74
|
+
export interface RecallMove {
|
|
75
|
+
readonly state: RecallState;
|
|
76
|
+
/** `null` ⇒ nothing moved; the caller must NOT write to the buffer. */
|
|
77
|
+
readonly text: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const REST: RecallState = {
|
|
81
|
+
position: { kind: "draft" },
|
|
82
|
+
stashedDraft: null,
|
|
83
|
+
lastRecalled: null,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The queue rung exists only when the newest queued prompt has content. */
|
|
87
|
+
function queuedRung(ladder: RecallLadder): string | null {
|
|
88
|
+
const last = ladder.queued[ladder.queued.length - 1];
|
|
89
|
+
return last !== undefined && last !== "" ? last : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Step UP one rung.
|
|
94
|
+
*
|
|
95
|
+
* `draft` is the current buffer, stashed on the first step so ArrowDown can
|
|
96
|
+
* return it. Returns `text: null` when there is nowhere to go, which is how the
|
|
97
|
+
* caller knows to leave the key to the textarea rather than swallowing it.
|
|
98
|
+
*/
|
|
99
|
+
export function recallPrevious(
|
|
100
|
+
state: RecallState,
|
|
101
|
+
ladder: RecallLadder,
|
|
102
|
+
draft: string,
|
|
103
|
+
): RecallMove {
|
|
104
|
+
// Stash before anything else, and only at rest: `stashedDraft` is the user's
|
|
105
|
+
// text, and overwriting it mid-walk with a recalled entry loses it silently.
|
|
106
|
+
const stashedDraft = state.position.kind === "draft" ? draft : state.stashedDraft;
|
|
107
|
+
|
|
108
|
+
const step = (position: RecallPosition, text: string): RecallMove => ({
|
|
109
|
+
state: { position, stashedDraft, lastRecalled: text },
|
|
110
|
+
text,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (state.position.kind === "draft") {
|
|
114
|
+
const pending = queuedRung(ladder);
|
|
115
|
+
if (pending !== null) return step({ kind: "queued" }, pending);
|
|
116
|
+
const newest = ladder.sent.length - 1;
|
|
117
|
+
if (newest < 0) return { state, text: null };
|
|
118
|
+
return step({ kind: "sent", index: newest }, ladder.sent[newest] ?? "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (state.position.kind === "queued") {
|
|
122
|
+
const newest = ladder.sent.length - 1;
|
|
123
|
+
if (newest < 0) return { state, text: null };
|
|
124
|
+
return step({ kind: "sent", index: newest }, ladder.sent[newest] ?? "");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const next = state.position.index - 1;
|
|
128
|
+
// At the oldest entry: stay. Wrapping to the newest would make a long ladder
|
|
129
|
+
// an infinite loop with no way to tell you had reached the end.
|
|
130
|
+
if (next < 0) return { state, text: null };
|
|
131
|
+
return step({ kind: "sent", index: next }, ladder.sent[next] ?? "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Step DOWN one rung, ending at the stashed draft.
|
|
136
|
+
*
|
|
137
|
+
* At rest this does nothing and returns `text: null` — ArrowDown in an untouched
|
|
138
|
+
* composer belongs to the textarea.
|
|
139
|
+
*/
|
|
140
|
+
export function recallNext(state: RecallState, ladder: RecallLadder): RecallMove {
|
|
141
|
+
if (state.position.kind === "draft") return { state, text: null };
|
|
142
|
+
|
|
143
|
+
const land = (): RecallMove => {
|
|
144
|
+
const text = state.stashedDraft ?? "";
|
|
145
|
+
// Back at rest: `lastRecalled` is cleared with the same write, so the
|
|
146
|
+
// restored draft is the USER's again and a further arrow will not navigate
|
|
147
|
+
// unless the buffer is empty.
|
|
148
|
+
return { state: REST, text };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (state.position.kind === "queued") return land();
|
|
152
|
+
|
|
153
|
+
const next = state.position.index + 1;
|
|
154
|
+
if (next < ladder.sent.length) {
|
|
155
|
+
const text = ladder.sent[next] ?? "";
|
|
156
|
+
return {
|
|
157
|
+
state: { ...state, position: { kind: "sent", index: next }, lastRecalled: text },
|
|
158
|
+
text,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Past the newest sent entry: the queued rung, then the draft.
|
|
163
|
+
const pending = queuedRung(ladder);
|
|
164
|
+
if (pending !== null) {
|
|
165
|
+
return {
|
|
166
|
+
state: { ...state, position: { kind: "queued" }, lastRecalled: pending },
|
|
167
|
+
text: pending,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return land();
|
|
171
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to do when a room's revision moves.
|
|
3
|
+
*
|
|
4
|
+
* Split from the hook so the rule is testable without a renderer: every way
|
|
5
|
+
* live-append can misbehave — reloading over a live stream, dropping a message
|
|
6
|
+
* that arrived mid-turn, re-fetching the rows just read at mount — is decided
|
|
7
|
+
* here.
|
|
8
|
+
*/
|
|
9
|
+
export type ReloadDecision = "reload" | "defer" | "ignore";
|
|
10
|
+
|
|
11
|
+
export interface ReloadInputs {
|
|
12
|
+
/** The room's current revision. */
|
|
13
|
+
readonly revision: number;
|
|
14
|
+
/** The last revision this reader acted on (or mounted with). */
|
|
15
|
+
readonly seen: number;
|
|
16
|
+
/** True while a turn is streaming into the transcript. */
|
|
17
|
+
readonly streaming: boolean;
|
|
18
|
+
/** True when an earlier change was deferred and has not yet been served. */
|
|
19
|
+
readonly deferred: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function reloadDecision(input: ReloadInputs): ReloadDecision {
|
|
23
|
+
const changed = input.revision !== input.seen;
|
|
24
|
+
|
|
25
|
+
// Mid-stream, a change waits. Seeding replaces the transcript wholesale and
|
|
26
|
+
// clears the streaming id, so reloading now would delete the reply as it is
|
|
27
|
+
// being written.
|
|
28
|
+
if (input.streaming) return changed || input.deferred ? "defer" : "ignore";
|
|
29
|
+
|
|
30
|
+
// The stream ended with a change owed — serve it now. Without this branch a
|
|
31
|
+
// message that landed mid-turn stays invisible until the room is reopened,
|
|
32
|
+
// which is the original defect wearing a narrower window.
|
|
33
|
+
if (input.deferred) return "reload";
|
|
34
|
+
|
|
35
|
+
return changed ? "reload" : "ignore";
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// How many messages arrived while the reader was away.
|
|
2
|
+
//
|
|
3
|
+
// In core/ because none of it is DOM. HOW to follow the bottom is not here and
|
|
4
|
+
// must not come back: it needs the engine's own scroll settle to blend against,
|
|
5
|
+
// which a pure model cannot see, and a hand-written version shook on every
|
|
6
|
+
// fling. `use-stick-to-bottom` owns the motion, wired in primitives/.
|
|
7
|
+
//
|
|
8
|
+
// WHETHER an arrival was the reader's own send is a different question, and one
|
|
9
|
+
// the library cannot answer at all — `isOwnSend` decides it from the transcript
|
|
10
|
+
// alone, which is why it is testable here and the animation is not.
|
|
11
|
+
//
|
|
12
|
+
// May import: utils.
|
|
13
|
+
|
|
14
|
+
export { MissedCount, missedRows } from "./missed";
|
|
15
|
+
export type { MissedRole } from "./missed";
|
|
16
|
+
export { isOwnSend } from "./sent";
|
|
17
|
+
export type { SentRole } from "./sent";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many replies arrived while the reader was scrolled away.
|
|
3
|
+
*
|
|
4
|
+
* A plain class for the same reason as `BottomPin`: nothing here is DOM. What
|
|
5
|
+
* is hard is deciding when the count starts, when it resets, and what counts —
|
|
6
|
+
* and all three are wrong in ways a rendered badge cannot show you.
|
|
7
|
+
*
|
|
8
|
+
* No store: the transcript already knows the message list and the pin state, so
|
|
9
|
+
* a second owner for "how far behind is the reader" could disagree with the
|
|
10
|
+
* scroll position it is describing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The reader's own messages never count.
|
|
15
|
+
*
|
|
16
|
+
* Sending scrolls them to the bottom anyway, so a `user` row can only enter the
|
|
17
|
+
* count in a race — and a badge that says "1 new" pointing at your own sentence
|
|
18
|
+
* is worse than no badge. Mirrors the server's unread rule, which excludes
|
|
19
|
+
* `author_kind = 'human'` for exactly this reason.
|
|
20
|
+
*/
|
|
21
|
+
export type MissedRole = "user" | "assistant" | "system";
|
|
22
|
+
|
|
23
|
+
export class MissedCount {
|
|
24
|
+
/**
|
|
25
|
+
* The last row present when the reader left the bottom. Null while pinned.
|
|
26
|
+
*
|
|
27
|
+
* An id, not a length and not a set of every id seen. A length breaks when
|
|
28
|
+
* history loads ABOVE — the list grows without anything arriving. A set
|
|
29
|
+
* breaks the same way for the opposite reason: prepended rows are absent from
|
|
30
|
+
* it and read as new. New messages append, so the last row is the frontier,
|
|
31
|
+
* and everything after it is what the reader missed.
|
|
32
|
+
*/
|
|
33
|
+
private frontier: string | null = null;
|
|
34
|
+
/** Distinguishes "pinned, no frontier" from "unpinned on an empty list". */
|
|
35
|
+
private armed = false;
|
|
36
|
+
private count = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Feed the current state after every render.
|
|
40
|
+
*
|
|
41
|
+
* @param pinned whether the viewport is following the bottom
|
|
42
|
+
* @param rows every message currently in the transcript, in order
|
|
43
|
+
*/
|
|
44
|
+
observe(pinned: boolean, rows: readonly { id: string; role: MissedRole }[]): void {
|
|
45
|
+
if (pinned) {
|
|
46
|
+
this.frontier = null;
|
|
47
|
+
this.armed = false;
|
|
48
|
+
this.count = 0;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// The FIRST unpinned observation sets the frontier. An empty transcript
|
|
52
|
+
// leaves it null, which correctly means every later row is new.
|
|
53
|
+
if (!this.armed) {
|
|
54
|
+
this.frontier = rows.at(-1)?.id ?? null;
|
|
55
|
+
this.armed = true;
|
|
56
|
+
}
|
|
57
|
+
const at = this.frontier === null ? -1 : rows.findIndex((row) => row.id === this.frontier);
|
|
58
|
+
// A frontier that vanished (a cleared transcript, a room switch) must not
|
|
59
|
+
// count the whole list: -1 there would make every row new. Re-arm instead.
|
|
60
|
+
if (this.frontier !== null && at === -1) {
|
|
61
|
+
this.frontier = rows.at(-1)?.id ?? null;
|
|
62
|
+
this.count = 0;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.count = rows.slice(at + 1).filter((row) => row.role !== "user").length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The badge's number, or null when there is nothing honest to show.
|
|
70
|
+
*
|
|
71
|
+
* Null rather than 0 so a caller cannot render an empty badge: "the button is
|
|
72
|
+
* showing but nothing arrived" and "1 arrived" are different states, and only
|
|
73
|
+
* the second deserves a number.
|
|
74
|
+
*/
|
|
75
|
+
value(): number | null {
|
|
76
|
+
return this.count > 0 ? this.count : null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The transcript's id list paired with the roles the store holds for it.
|
|
82
|
+
*
|
|
83
|
+
* Separate from the React binding so the one decision it makes is testable
|
|
84
|
+
* without a DOM: what to do with an id the store has no message for. It DROPS
|
|
85
|
+
* that row. The alternative — defaulting a role — has to pick one, and every
|
|
86
|
+
* choice is wrong in a way the badge shows: `assistant` counts a phantom and
|
|
87
|
+
* sends the reader down to a row that is not there, `user` silently suppresses
|
|
88
|
+
* a real reply if the guess is ever wrong.
|
|
89
|
+
*
|
|
90
|
+
* `ids` and the message map are written together in every store path, so the
|
|
91
|
+
* case cannot arise today. It is written down because the two consumers of that
|
|
92
|
+
* invariant had drifted apart: `selectMessages` already drops the missing id,
|
|
93
|
+
* while this side used to substitute `assistant`.
|
|
94
|
+
*
|
|
95
|
+
* Structural parameter rather than the store's own types: `core` may import
|
|
96
|
+
* only `utils`.
|
|
97
|
+
*/
|
|
98
|
+
export function missedRows(
|
|
99
|
+
ids: readonly string[],
|
|
100
|
+
byId: Readonly<Record<string, { readonly role: MissedRole } | undefined>>,
|
|
101
|
+
): readonly { id: string; role: MissedRole }[] {
|
|
102
|
+
return ids.flatMap((id) => {
|
|
103
|
+
const message = byId[id];
|
|
104
|
+
return message === undefined ? [] : [{ id, role: message.role }];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did the reader just SEND, as opposed to receive?
|
|
3
|
+
*
|
|
4
|
+
* `use-stick-to-bottom` only follows growth while the view is already pinned, so
|
|
5
|
+
* a reader scrolled into history stayed there when their own message landed —
|
|
6
|
+
* the room looked as if nothing had been sent. Returning them is a different
|
|
7
|
+
* question from "follow a streaming reply", and nothing answered it.
|
|
8
|
+
*
|
|
9
|
+
* The signal is the transcript itself rather than an event, because every entry
|
|
10
|
+
* point ends there — Enter, the send button, a host calling `store.send`, a
|
|
11
|
+
* queued prompt draining. `turn:sent` fires only on the keyboard path, so a
|
|
12
|
+
* listener would have been right about a quarter of the ways to send.
|
|
13
|
+
*
|
|
14
|
+
* Pure, and here rather than in the hook, for the same reason as `MissedCount`:
|
|
15
|
+
* the three cases it must tell apart are not visible in a rendered scroll
|
|
16
|
+
* position, and getting any of them wrong is silent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Only the reader's own turn asks to be shown the bottom. */
|
|
20
|
+
export type SentRole = "user" | "assistant" | "system";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param previous the id list as last observed, or null before the first one
|
|
24
|
+
* @param ids the id list now
|
|
25
|
+
* @param roleOf the role the store holds for an id, or undefined if it has none
|
|
26
|
+
*/
|
|
27
|
+
export function isOwnSend(
|
|
28
|
+
previous: readonly string[] | null,
|
|
29
|
+
ids: readonly string[],
|
|
30
|
+
roleOf: (id: string) => SentRole | undefined,
|
|
31
|
+
): boolean {
|
|
32
|
+
// Nothing to compare against: the first observation is the room's existing
|
|
33
|
+
// history, not something that just happened.
|
|
34
|
+
if (previous === null) return false;
|
|
35
|
+
|
|
36
|
+
// A send APPENDS exactly one id and leaves the rest identical.
|
|
37
|
+
//
|
|
38
|
+
// Compared as a WHOLE list, not by its tail, because the store outlives a room
|
|
39
|
+
// switch: `load` replaces the transcript wholesale, and a room whose history
|
|
40
|
+
// happens to end on a user message would otherwise read as a send and animate
|
|
41
|
+
// a scroll every time it is opened. History paged in ABOVE fails this too,
|
|
42
|
+
// which is correct — the reader asked for those rows where they are.
|
|
43
|
+
if (ids.length !== previous.length + 1) return false;
|
|
44
|
+
for (let index = 0; index < previous.length; index += 1) {
|
|
45
|
+
if (ids[index] !== previous[index]) return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const last = ids[ids.length - 1];
|
|
49
|
+
if (last === undefined) return false;
|
|
50
|
+
// The assistant opening its reply also appends exactly one id. Following that
|
|
51
|
+
// would drag a reader out of the history they scrolled up to read, which is
|
|
52
|
+
// what the jump-to-latest control exists to offer instead of forcing.
|
|
53
|
+
return roleOf(last) === "user";
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting text into a composer that may already hold some.
|
|
3
|
+
*
|
|
4
|
+
* Three callers need this and each wrote its own: recall's Tab commit, a slash
|
|
5
|
+
* pick, and taking a queued prompt back for editing. They disagreed on the one
|
|
6
|
+
* question that matters — what happens to what the user already typed — so the
|
|
7
|
+
* rule lives here once.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Would this write destroy something? `false` ⇒ write, and do NOT prompt.
|
|
12
|
+
*
|
|
13
|
+
* An empty field has nothing to lose, and re-inserting text the field already
|
|
14
|
+
* holds is a no-op the user would read as a pointless question.
|
|
15
|
+
*/
|
|
16
|
+
export function needsChoice(current: string, incoming: string): boolean {
|
|
17
|
+
const held = current.trim();
|
|
18
|
+
if (held === "") return false;
|
|
19
|
+
return held !== incoming.trim();
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The qualifier an MCP client prepends to every tool it borrows from a server.
|
|
3
|
+
*
|
|
4
|
+
* Tools are declared BARE on the server — it registers `ask_weather`, not
|
|
5
|
+
* `mcp__server__ask_weather`. The prefix is the client's
|
|
6
|
+
* namespacing, so stripping it restores the name we chose rather than inventing
|
|
7
|
+
* a nickname for a name we did not.
|
|
8
|
+
*
|
|
9
|
+
* Lazy on purpose: it must close at the FIRST separator, because `__` is legal
|
|
10
|
+
* inside a tool's own name and a greedy match would eat into it.
|
|
11
|
+
*/
|
|
12
|
+
const QUALIFIED = /^mcp__.+?__/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The tool name as a reader should see it.
|
|
16
|
+
*
|
|
17
|
+
* Exactly two segments come off — the marker and the server — because `__` is
|
|
18
|
+
* legal INSIDE a tool's own name. Taking the text after the last separator
|
|
19
|
+
* instead would silently shorten any such tool, and nothing would fail: the row
|
|
20
|
+
* still renders, just under half a name.
|
|
21
|
+
*
|
|
22
|
+
* A name that is not qualified passes through untouched, which is what keeps
|
|
23
|
+
* one engine's rows (`ToolSearch`) sitting level with another's in the same
|
|
24
|
+
* transcript.
|
|
25
|
+
*/
|
|
26
|
+
export function readableToolName(toolName: string): string {
|
|
27
|
+
const bare = toolName.replace(QUALIFIED, "");
|
|
28
|
+
// A name that is ONLY its qualifier leaves nothing to render; the raw string
|
|
29
|
+
// is a worse label than the original, so the original stands.
|
|
30
|
+
return bare === "" ? toolName : bare;
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The picked-tool list as a SET, keyed by id.
|
|
2
|
+
//
|
|
3
|
+
// May import: ./tool-picks.
|
|
4
|
+
|
|
5
|
+
import type { ToolPick } from "./tool-picks";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Add a pick unless its id is already staged.
|
|
9
|
+
*
|
|
10
|
+
* Uniqueness belongs to the list, not to each caller: three paths write it —
|
|
11
|
+
* the menu, a paste carrying a marker, a restored draft — and a rule enforced
|
|
12
|
+
* at three call sites is one an unrelated fourth breaks. A duplicate is not
|
|
13
|
+
* cosmetic either: `appendToolMarkers` would write the marker twice and the
|
|
14
|
+
* model would read two requests.
|
|
15
|
+
*
|
|
16
|
+
* Returns the SAME array when nothing changes, so a no-op cannot re-render.
|
|
17
|
+
*/
|
|
18
|
+
export function addToolPick(
|
|
19
|
+
picks: readonly ToolPick[],
|
|
20
|
+
pick: ToolPick,
|
|
21
|
+
): readonly ToolPick[] {
|
|
22
|
+
return picks.some((staged) => staged.id === pick.id) ? picks : [...picks, pick];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Add several, in order, each subject to the same rule. */
|
|
26
|
+
export function addToolPicks(
|
|
27
|
+
picks: readonly ToolPick[],
|
|
28
|
+
incoming: readonly ToolPick[],
|
|
29
|
+
): readonly ToolPick[] {
|
|
30
|
+
return incoming.reduce(addToolPick, picks);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function removeToolPick(picks: readonly ToolPick[], id: string): readonly ToolPick[] {
|
|
34
|
+
return picks.filter((pick) => pick.id !== id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The menu's verb: staged ⇒ remove, absent ⇒ add.
|
|
39
|
+
*
|
|
40
|
+
* A toggle rather than an add because the menu row is the same control either
|
|
41
|
+
* way — a second press has to mean "undo", and silently ignoring it reads as a
|
|
42
|
+
* dead row.
|
|
43
|
+
*/
|
|
44
|
+
export function toggleToolPick(picks: readonly ToolPick[], pick: ToolPick): readonly ToolPick[] {
|
|
45
|
+
return picks.some((staged) => staged.id === pick.id)
|
|
46
|
+
? removeToolPick(picks, pick.id)
|
|
47
|
+
: [...picks, pick];
|
|
48
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tool the user picked from the menu, and the marker that carries it.
|
|
3
|
+
*
|
|
4
|
+
* The marker reaches the MODEL, not the transcript. It says "the user chose
|
|
5
|
+
* this from the menu" in a form that survives a sentence around it, which a
|
|
6
|
+
* bare name cannot: `upload_file` in prose is indistinguishable from a word
|
|
7
|
+
* that was typed, and that ambiguity is the whole reason the menu exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toolURL } from "./links";
|
|
11
|
+
|
|
12
|
+
/** A picked tool: the registry's id, and the label the user actually saw. */
|
|
13
|
+
export interface ToolPick {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/** What the menu row said. Shown on the chip; never sent. */
|
|
16
|
+
readonly label: string;
|
|
17
|
+
/**
|
|
18
|
+
* The entry's icon KEY, carried so the chip can draw the same glyph the menu
|
|
19
|
+
* row did. Absent ⇒ no glyph; the id is not a fallback, because a host maps
|
|
20
|
+
* these keys and an id it never mapped would resolve to nothing anyway.
|
|
21
|
+
*/
|
|
22
|
+
readonly icon?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Carried from the catalogue row so the composer can say what is still
|
|
25
|
+
* missing without holding the catalogue itself — the draft is what the
|
|
26
|
+
* placeholder reads, and a pick is the only thing on it that knows.
|
|
27
|
+
*/
|
|
28
|
+
readonly needs?: "file" | "skill";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The marker a picked tool becomes in the outbound text: a markdown link
|
|
33
|
+
* `[<label>](app://tool/<id>)`.
|
|
34
|
+
*
|
|
35
|
+
* A link rather than a bare token because the same string has two readers. The
|
|
36
|
+
* MODEL reads the label, which is the human name of the tool and says more than
|
|
37
|
+
* an id does; the RENDERER reads the href, which is the exact registry id and
|
|
38
|
+
* is what a chip must resolve. A bare `[tool:<id>]` served only the second, and
|
|
39
|
+
* printed as noise to the first.
|
|
40
|
+
*
|
|
41
|
+
* An empty label falls back to the id — a link with no text renders as nothing
|
|
42
|
+
* at all, which would hide the pick from the model entirely.
|
|
43
|
+
*/
|
|
44
|
+
export function toolMarker(id: string, label: string): string {
|
|
45
|
+
return `[${escapeLinkText(label || id)}](${toolURL(id)})`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Escape the characters that would end a markdown link's text early.
|
|
50
|
+
*
|
|
51
|
+
* Only `[` and `]` matter: either one unbalances the bracket pair, so the
|
|
52
|
+
* parser stops reading the label there and the href becomes visible prose.
|
|
53
|
+
* Labels rarely contain them, and a label that does must still round-trip.
|
|
54
|
+
*/
|
|
55
|
+
function escapeLinkText(label: string): string {
|
|
56
|
+
return label.replace(/([[\]])/g, "\\$1");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ids named by markers in a text, in the order they appear.
|
|
61
|
+
*
|
|
62
|
+
* For a host that must reconcile what a user typed against what they picked.
|
|
63
|
+
* Matched on the HREF, not the label: the id is the half that identifies the
|
|
64
|
+
* tool, and a label may be renamed, translated, or typed by the user.
|
|
65
|
+
*
|
|
66
|
+
* The label is skipped with an escape-aware scan so a `\]` inside it cannot end
|
|
67
|
+
* the pair early — the other half of what `escapeLinkText` writes.
|
|
68
|
+
*/
|
|
69
|
+
export function readToolMarkers(text: string): readonly string[] {
|
|
70
|
+
const found: string[] = [];
|
|
71
|
+
for (const match of text.matchAll(/\[(?:\\.|[^\\[\]])*\]\(app:\/\/tool\/([^)\s]+)\)/g)) {
|
|
72
|
+
// The group is guaranteed by the pattern, but not by the type: an indexed
|
|
73
|
+
// read is `string | undefined` under the compiler's checked-access rule.
|
|
74
|
+
const raw = match[1];
|
|
75
|
+
if (raw === undefined) continue;
|
|
76
|
+
// The writer percent-encodes the id, so the reader must decode it or an id
|
|
77
|
+
// with a reserved character never matches the pick it came from.
|
|
78
|
+
try {
|
|
79
|
+
found.push(decodeURIComponent(raw));
|
|
80
|
+
} catch {
|
|
81
|
+
found.push(raw);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Append each pick's marker to the text the model receives.
|
|
89
|
+
*
|
|
90
|
+
* Appended at the END, never at the caret: the markers describe the whole turn,
|
|
91
|
+
* and splicing one into the middle of a sentence would change what the prose
|
|
92
|
+
* around it means.
|
|
93
|
+
*
|
|
94
|
+
* A pick already named in the text is not repeated — a user who typed the
|
|
95
|
+
* marker themselves, or a draft restored from storage, would otherwise send it
|
|
96
|
+
* twice and read as two requests.
|
|
97
|
+
*/
|
|
98
|
+
export function appendToolMarkers(text: string, picks: readonly ToolPick[] | undefined): string {
|
|
99
|
+
// Tolerates a draft built before this field existed. `Draft` crosses the
|
|
100
|
+
// package boundary, so a host or a stored draft can still carry the old
|
|
101
|
+
// shape, and a missing list means "picked nothing" rather than a crash on
|
|
102
|
+
// the send path.
|
|
103
|
+
if (picks === undefined || picks.length === 0) return text;
|
|
104
|
+
|
|
105
|
+
const already = new Set(readToolMarkers(text));
|
|
106
|
+
const markers = picks
|
|
107
|
+
.filter((pick) => !already.has(pick.id))
|
|
108
|
+
.map((pick) => toolMarker(pick.id, pick.label));
|
|
109
|
+
if (markers.length === 0) return text;
|
|
110
|
+
|
|
111
|
+
const body = text.trim();
|
|
112
|
+
return body === "" ? markers.join(" ") : `${body} ${markers.join(" ")}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A text with its tool markers removed, and the ids they named. */
|
|
116
|
+
export interface StrippedToolMarkers {
|
|
117
|
+
readonly text: string;
|
|
118
|
+
readonly ids: readonly string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split pasted text into prose and the tools it names.
|
|
123
|
+
*
|
|
124
|
+
* The marker is what `buildOutbound` WRITES, so copying a sent turn and pasting
|
|
125
|
+
* it back returns raw markdown to the box. The user sees a URL they did not
|
|
126
|
+
* type and a tool nothing staged — the round trip loses exactly the fact the
|
|
127
|
+
* marker exists to carry.
|
|
128
|
+
*
|
|
129
|
+
* Ids only. Whether a tool is offered here is the catalogue's answer, and a
|
|
130
|
+
* label lifted from the text would name a tool this host may not register.
|
|
131
|
+
*/
|
|
132
|
+
export function stripToolMarkers(text: string): StrippedToolMarkers {
|
|
133
|
+
const ids = readToolMarkers(text);
|
|
134
|
+
if (ids.length === 0) return { text, ids };
|
|
135
|
+
|
|
136
|
+
// Collapse the space the marker was joined with, so removing a trailing one
|
|
137
|
+
// does not leave the prose ending in a blank.
|
|
138
|
+
const stripped = text
|
|
139
|
+
.replace(/\s*\[(?:\\.|[^\\[\]])*\]\(app:\/\/tool\/[^)\s]+\)/g, "")
|
|
140
|
+
.trim();
|
|
141
|
+
return { text: stripped, ids };
|
|
142
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the composer should say once a tool is picked.
|
|
3
|
+
*
|
|
4
|
+
* Picking states an intent, and for most tools the object arrives as the
|
|
5
|
+
* sentence typed next — which is why the caret lands in the field and why this
|
|
6
|
+
* returns nothing for them. A few tools want something a sentence cannot carry,
|
|
7
|
+
* and today the interface stays silent: the turn sends, and the agent asks a
|
|
8
|
+
* question the composer could have prevented.
|
|
9
|
+
*
|
|
10
|
+
* A HINT, never a gate. The turn still sends without the file; the agent still
|
|
11
|
+
* asks. This only removes the round-trip when the user would have known.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Draft } from "./outbound";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a picked tool still wants. Mirrors the generated `ChatTool.needs`, which
|
|
18
|
+
* is derived in Go from the tool's own required arguments.
|
|
19
|
+
*/
|
|
20
|
+
export type ToolNeed = "file" | "skill";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The unmet need of the picks on this draft, or null when there is nothing to
|
|
24
|
+
* say.
|
|
25
|
+
*
|
|
26
|
+
* Reads the DRAFT alone. The need travels on the pick, so this needs no
|
|
27
|
+
* catalogue and no resolver — a second lookup here would be a second place that
|
|
28
|
+
* can disagree with the row the user actually clicked.
|
|
29
|
+
*
|
|
30
|
+
* A `file` need is ALWAYS unmet: this surface is text-only, so nothing on the
|
|
31
|
+
* draft can answer it. The hint is what tells the user to name the file in
|
|
32
|
+
* words rather than reach for an affordance that is not there.
|
|
33
|
+
*
|
|
34
|
+
* The FIRST unmet need wins. Two picks wanting two different things is a
|
|
35
|
+
* sentence, not a placeholder, and stacking them would produce a line nobody
|
|
36
|
+
* can read — the second returns as soon as the first is met.
|
|
37
|
+
*/
|
|
38
|
+
export function unmetNeed(draft: Draft): ToolNeed | null {
|
|
39
|
+
const picks = draft.pickedTools ?? [];
|
|
40
|
+
if (picks.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
for (const pick of picks) {
|
|
43
|
+
if (pick.needs === undefined) continue;
|
|
44
|
+
return pick.needs;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|