@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound payload has ONE owner, and it is this file.
|
|
3
|
+
*
|
|
4
|
+
* Everything a turn sends passes through `buildOutbound`. A caller that
|
|
5
|
+
* assembles text itself is the bug, not a shortcut.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChatMessage, MessagePart } from "./parts";
|
|
9
|
+
import { appendToolMarkers, type ToolPick } from "./tool-picks";
|
|
10
|
+
|
|
11
|
+
export interface Draft {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
/**
|
|
14
|
+
* Tools picked from the menu, in the order they were picked.
|
|
15
|
+
*
|
|
16
|
+
* They reach the model as markers appended by `buildOutbound`, and the
|
|
17
|
+
* transcript not at all — the sent bubble shows what the user wrote.
|
|
18
|
+
*
|
|
19
|
+
* Optional because `Draft` crosses the package boundary: a host's own draft,
|
|
20
|
+
* or one restored from storage written before this field existed, carries no
|
|
21
|
+
* list, and absent must mean "picked nothing" rather than a send-path crash.
|
|
22
|
+
*/
|
|
23
|
+
readonly pickedTools?: readonly ToolPick[];
|
|
24
|
+
/**
|
|
25
|
+
* The message this draft answers, by the host's own id.
|
|
26
|
+
*
|
|
27
|
+
* State the draft genuinely owns, unlike an addressee: addressing is written
|
|
28
|
+
* in the text, while "which message" has nowhere else to live and rides the
|
|
29
|
+
* send as its own field.
|
|
30
|
+
*/
|
|
31
|
+
readonly replyTo?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const emptyDraft: Draft = { text: "", pickedTools: [] };
|
|
35
|
+
|
|
36
|
+
/** Nothing to send. */
|
|
37
|
+
export function isEmpty(draft: Draft): boolean {
|
|
38
|
+
// A picked tool alone IS sendable: choosing one from the menu is a request,
|
|
39
|
+
// and refusing to send it would leave the pick visible with no way to act
|
|
40
|
+
// on it.
|
|
41
|
+
return draft.text.trim() === "" && (draft.pickedTools?.length ?? 0) === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Outbound {
|
|
45
|
+
/** What the model receives: the typed text, with tool markers appended. */
|
|
46
|
+
readonly text: string;
|
|
47
|
+
/** What the transcript shows: the user's own words. */
|
|
48
|
+
readonly message: ChatMessage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build both renderings of one turn at once.
|
|
53
|
+
*
|
|
54
|
+
* They are deliberately different and deliberately built together: the model
|
|
55
|
+
* receives the tool markers, the transcript shows only what the user wrote.
|
|
56
|
+
* Deriving one from the other later is how they drift — and a drift here means
|
|
57
|
+
* the transcript lies about what was sent.
|
|
58
|
+
*/
|
|
59
|
+
export function buildOutbound(draft: Draft, id: string, createdAt: number): Outbound {
|
|
60
|
+
const parts: MessagePart[] = [];
|
|
61
|
+
if (draft.text.trim() !== "") {
|
|
62
|
+
parts.push({ type: "text", id: `${id}:text`, text: draft.text });
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
// Markers land on the MODEL's rendering alone. In `parts` the sent bubble
|
|
66
|
+
// would carry a link the user never typed, where they expect their own
|
|
67
|
+
// words.
|
|
68
|
+
text: appendToolMarkers(draft.text, draft.pickedTools),
|
|
69
|
+
message: {
|
|
70
|
+
id,
|
|
71
|
+
role: "user",
|
|
72
|
+
parts,
|
|
73
|
+
status: { state: "complete" },
|
|
74
|
+
createdAt,
|
|
75
|
+
// Spread, so the field is ABSENT rather than "" when nothing is answered:
|
|
76
|
+
// the quote keys on `undefined`, and an empty id would send it looking up
|
|
77
|
+
// a message that cannot exist.
|
|
78
|
+
//
|
|
79
|
+
// The bubble the sender sees is built here, not read back from the wire.
|
|
80
|
+
// Dropping it would leave the sender with no quote while every other
|
|
81
|
+
// reader — who loads the turn from storage, which does persist it — sees
|
|
82
|
+
// one, so the same message would render two ways.
|
|
83
|
+
...(draft.replyTo ? { replyTo: draft.replyTo } : {}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message parts — the extension seam of this package.
|
|
3
|
+
*
|
|
4
|
+
* A new capability adds a PART TYPE here, never a prop to `<Chat>`. That is the
|
|
5
|
+
* only shape whose API cost stays flat as the product grows: the predecessor
|
|
6
|
+
* grew a prop per feature and reached 83, at which point no one could enumerate
|
|
7
|
+
* the configurations it claimed to support (`@rules/architecture.md`).
|
|
8
|
+
*
|
|
9
|
+
* Shape follows the Vercel AI SDK's `message.parts` (a discriminated union keyed
|
|
10
|
+
* on `type`, tool parts keyed by name) because the whole ecosystem converged on
|
|
11
|
+
* it. We copy the SHAPE, not the package — its majors broke the API twice.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stable identity for a streamed part.
|
|
16
|
+
*
|
|
17
|
+
* Two consumers need it and neither can improvise one: the reducer routes
|
|
18
|
+
* deltas by it, and React keys rows by it. An array index cannot serve — `reset`
|
|
19
|
+
* empties the parts array mid-turn, so index 0 legitimately becomes a different
|
|
20
|
+
* part and React reconciles the old DOM into it.
|
|
21
|
+
*/
|
|
22
|
+
export interface PartIdentity {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Plain assistant or user prose. Markdown is a rendering concern, not a type. */
|
|
27
|
+
export interface TextPart extends PartIdentity {
|
|
28
|
+
readonly type: "text";
|
|
29
|
+
readonly text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Model thinking. Separate from `text` because it collapses by default and is
|
|
34
|
+
* never part of the copyable answer.
|
|
35
|
+
*/
|
|
36
|
+
export interface ReasoningPart extends PartIdentity {
|
|
37
|
+
readonly type: "reasoning";
|
|
38
|
+
readonly text: string;
|
|
39
|
+
/**
|
|
40
|
+
* Still thinking. The block shows a live label while true and a settled
|
|
41
|
+
* summary once false.
|
|
42
|
+
*
|
|
43
|
+
* Needed because a thought is sealed by the NEXT part opening, not by a
|
|
44
|
+
* chunk of its own — without this the transcript cannot tell a thought still
|
|
45
|
+
* arriving from one that finished, and both would render identically.
|
|
46
|
+
*/
|
|
47
|
+
readonly streaming?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Measured wall time, when the engine measured it. Absent is ordinary — a
|
|
50
|
+
* driven CLI engine reports no duration — so the block must read without it.
|
|
51
|
+
*/
|
|
52
|
+
readonly elapsedMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Reasoning tokens spent, when the engine reports them.
|
|
55
|
+
*
|
|
56
|
+
* The only true thing left to show when a provider ships the thinking block
|
|
57
|
+
* with its words stripped: `text` is "" and this is not. A block keyed on
|
|
58
|
+
* text alone renders nothing on the engine most likely to be running.
|
|
59
|
+
*/
|
|
60
|
+
readonly tokens?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tool-call lifecycle. Four states, and the transition carries the payload —
|
|
65
|
+
* so a renderer can never read `output` before it exists.
|
|
66
|
+
*
|
|
67
|
+
* Modelled as a union rather than a `status` field plus optional `input`/
|
|
68
|
+
* `output`: the predecessor's `ToolCall` had `status` alongside optional
|
|
69
|
+
* `output`, which let "success with no output" and "still running" become the
|
|
70
|
+
* same value at the type level.
|
|
71
|
+
*/
|
|
72
|
+
export type ToolState =
|
|
73
|
+
/** Arguments are still streaming in; `input` is a partial JSON string. */
|
|
74
|
+
| { readonly state: "input-streaming"; readonly inputText: string }
|
|
75
|
+
/** Arguments complete, execution not finished. */
|
|
76
|
+
| { readonly state: "input-available"; readonly input: unknown }
|
|
77
|
+
| { readonly state: "output-available"; readonly input: unknown; readonly output: unknown }
|
|
78
|
+
| { readonly state: "output-error"; readonly input: unknown; readonly error: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One tool invocation.
|
|
82
|
+
*
|
|
83
|
+
* `messages` is present from day one deliberately, ahead of any renderer for it:
|
|
84
|
+
* a tool that spawns a subagent carries that subagent's whole conversation, and
|
|
85
|
+
* assistant-ui showed this is the shape that works (nested read-only thread,
|
|
86
|
+
* parent tool renderers inherited inward). Retrofitting a nested thread onto a
|
|
87
|
+
* flat transcript later is a rewrite of every selector; reserving the field now
|
|
88
|
+
* costs one line.
|
|
89
|
+
*/
|
|
90
|
+
export interface ToolPart extends PartIdentity {
|
|
91
|
+
readonly type: "tool";
|
|
92
|
+
/** `id` IS the tool-call id — the wire's `toolCallId` maps onto it directly.
|
|
93
|
+
* A second field holding the same value is a second source of truth. */
|
|
94
|
+
readonly toolName: string;
|
|
95
|
+
readonly toolState: ToolState;
|
|
96
|
+
/** Subagent transcript, when this tool ran one. Absent for an ordinary tool. */
|
|
97
|
+
readonly messages?: readonly ChatMessage[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Product-specific typed content the agent authored (map, chart, image card…).
|
|
102
|
+
* `data` stays `unknown`: the renderer registry validates per kind, so an
|
|
103
|
+
* unknown or malformed kind degrades to a fallback instead of widening this
|
|
104
|
+
* union every time the agent learns a new block.
|
|
105
|
+
*/
|
|
106
|
+
export interface DataPart extends PartIdentity {
|
|
107
|
+
readonly type: "data";
|
|
108
|
+
readonly kind: string;
|
|
109
|
+
readonly data: unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type MessagePart = TextPart | ReasoningPart | ToolPart | DataPart;
|
|
113
|
+
|
|
114
|
+
export type MessageRole = "user" | "assistant" | "system";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Terminal state of an assistant turn.
|
|
118
|
+
*
|
|
119
|
+
* `interrupted` and `failed` are distinct because the RECOVERY differs: an
|
|
120
|
+
* interrupted turn may still have been persisted by the server and reconciles on
|
|
121
|
+
* reconnect, while a failed one is a verdict the server already recorded. The
|
|
122
|
+
* predecessor learned this the expensive way — a failure rendered as an ordinary
|
|
123
|
+
* reply looks like the agent said it, which is worse than silence.
|
|
124
|
+
*/
|
|
125
|
+
export type MessageStatus =
|
|
126
|
+
| { readonly state: "streaming" }
|
|
127
|
+
| { readonly state: "complete" }
|
|
128
|
+
| { readonly state: "interrupted" }
|
|
129
|
+
| { readonly state: "failed"; readonly message: string };
|
|
130
|
+
|
|
131
|
+
export interface ChatMessage {
|
|
132
|
+
readonly id: string;
|
|
133
|
+
readonly role: MessageRole;
|
|
134
|
+
readonly parts: readonly MessagePart[];
|
|
135
|
+
readonly status: MessageStatus;
|
|
136
|
+
/** Wall-clock creation, millis. Ordering uses array position, never this. */
|
|
137
|
+
readonly createdAt: number;
|
|
138
|
+
/**
|
|
139
|
+
* WHO spoke this turn, as the host's own opaque reference — never a name.
|
|
140
|
+
*
|
|
141
|
+
* The package neither resolves nor renders it: a host with several speakers
|
|
142
|
+
* in one room passes a header slot that turns this into a line, and a host
|
|
143
|
+
* with one correspondent passes nothing. Absent on every turn the host does
|
|
144
|
+
* not attribute, which is the ordinary case.
|
|
145
|
+
*/
|
|
146
|
+
readonly authorId?: string;
|
|
147
|
+
/**
|
|
148
|
+
* The message this one answers, as the host's own id.
|
|
149
|
+
*
|
|
150
|
+
* Rendered only where the host supplies a quote resolver — the package holds
|
|
151
|
+
* no transcript index and will not search one. Absent is the ordinary case.
|
|
152
|
+
*/
|
|
153
|
+
readonly replyTo?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Narrowing helpers — cheaper than repeating the discriminant at every call site. */
|
|
157
|
+
export const isTextPart = (p: MessagePart): p is TextPart => p.type === "text";
|
|
158
|
+
export const isToolPart = (p: MessagePart): p is ToolPart => p.type === "tool";
|
|
159
|
+
|
|
160
|
+
/** A tool part that ran a subagent, i.e. one a nested thread can render. */
|
|
161
|
+
export function subagentMessages(part: ToolPart): readonly ChatMessage[] | undefined {
|
|
162
|
+
return part.messages !== undefined && part.messages.length > 0 ? part.messages : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True once the tool produced something a renderer can show. */
|
|
166
|
+
export function toolHasResult(part: ToolPart): boolean {
|
|
167
|
+
const s = part.toolState.state;
|
|
168
|
+
return s === "output-available" || s === "output-error";
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Prompts written while the agent is working.
|
|
2
|
+
//
|
|
3
|
+
// `store.send` used to open with `if (state.inFlight !== null) return;` — a
|
|
4
|
+
// second turn was discarded in silence. Our agents run for minutes, so that was
|
|
5
|
+
// exactly the moment a user most often had something to add.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
dequeue,
|
|
9
|
+
editQueued,
|
|
10
|
+
emptyQueue,
|
|
11
|
+
enqueue,
|
|
12
|
+
promoteQueued,
|
|
13
|
+
removeQueued,
|
|
14
|
+
} from "./queue";
|
|
15
|
+
export type { QueuedPrompt, QueueState } from "./queue";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isEmpty, type Draft } from "../outbound";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prompts written while the agent is working.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* `store.send` opened with `if (state.inFlight !== null) return;` — a second
|
|
9
|
+
* turn was DISCARDED, silently. Our agents run for minutes, so the moment a user
|
|
10
|
+
* most often has something to add is exactly the moment the composer threw it
|
|
11
|
+
* away. Nothing reported it: no error, no toast, the text simply vanished from
|
|
12
|
+
* the field.
|
|
13
|
+
*
|
|
14
|
+
* ## Draft objects, never strings
|
|
15
|
+
*
|
|
16
|
+
* A queued entry is a whole `Draft`. A queue of strings would drop the picked
|
|
17
|
+
* tools a turn carries beside its text. `core/outbound` already owns what a
|
|
18
|
+
* sendable turn is; this holds those.
|
|
19
|
+
*
|
|
20
|
+
* ## No settings
|
|
21
|
+
*
|
|
22
|
+
* Whether to queue is not a preference. Enter while streaming queues, always.
|
|
23
|
+
* A toggle here would be a question with no good default, asked of someone who
|
|
24
|
+
* is mid-sentence.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface QueuedPrompt {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly draft: Draft;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QueueState {
|
|
33
|
+
readonly items: readonly QueuedPrompt[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const emptyQueue: QueueState = { items: [] };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Append, unless the draft is empty.
|
|
40
|
+
*
|
|
41
|
+
* Empty is rejected here rather than at the call site so every entry point —
|
|
42
|
+
* keyboard, button, a host calling directly — obeys one rule. Queueing nothing
|
|
43
|
+
* would put a row in the strip that sends nothing when it drains.
|
|
44
|
+
*/
|
|
45
|
+
export function enqueue(state: QueueState, draft: Draft, id: string): QueueState {
|
|
46
|
+
if (isEmpty(draft)) return state;
|
|
47
|
+
return { items: [...state.items, { id, draft }] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Take the next prompt, or null.
|
|
52
|
+
*
|
|
53
|
+
* Returns the REMAINDER alongside it rather than mutating: the caller sends the
|
|
54
|
+
* draft and stores the rest, so a send that throws cannot leave the queue in a
|
|
55
|
+
* state where the prompt is both gone and unsent.
|
|
56
|
+
*/
|
|
57
|
+
export function dequeue(state: QueueState): {
|
|
58
|
+
readonly next: QueuedPrompt | null;
|
|
59
|
+
readonly rest: QueueState;
|
|
60
|
+
} {
|
|
61
|
+
const [next, ...rest] = state.items;
|
|
62
|
+
if (next === undefined) return { next: null, rest: state };
|
|
63
|
+
return { next, rest: { items: rest } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function removeQueued(state: QueueState, id: string): QueueState {
|
|
67
|
+
return { items: state.items.filter((item) => item.id !== id) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Move one prompt to the head, keeping the rest in order.
|
|
72
|
+
*
|
|
73
|
+
* "Send now" names a ROW, and the drain always takes the head — so without this
|
|
74
|
+
* the button beside the third prompt would release the first. Promotion rather
|
|
75
|
+
* than a send-this-one path because one code owner for "what goes next" is what
|
|
76
|
+
* keeps the forced release and the natural one from drifting apart.
|
|
77
|
+
*
|
|
78
|
+
* An unknown id returns the state unchanged rather than throwing: the row can be
|
|
79
|
+
* removed by another click between render and dispatch, and a queue that throws
|
|
80
|
+
* on a stale id would take the composer down for a race the user cannot see.
|
|
81
|
+
*/
|
|
82
|
+
export function promoteQueued(state: QueueState, id: string): QueueState {
|
|
83
|
+
const chosen = state.items.find((item) => item.id === id);
|
|
84
|
+
if (chosen === undefined) return state;
|
|
85
|
+
return { items: [chosen, ...state.items.filter((item) => item.id !== id)] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Replace a queued draft — the edit affordance.
|
|
90
|
+
*
|
|
91
|
+
* An edit to empty REMOVES the entry. The alternative is a row the user has
|
|
92
|
+
* emptied on purpose that still occupies the strip and still sends nothing, and
|
|
93
|
+
* "delete it yourself" is an extra action for something already expressed.
|
|
94
|
+
*/
|
|
95
|
+
export function editQueued(state: QueueState, id: string, draft: Draft): QueueState {
|
|
96
|
+
if (isEmpty(draft)) return removeQueued(state, id);
|
|
97
|
+
return {
|
|
98
|
+
items: state.items.map((item) => (item.id === id ? { ...item, draft } : item)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When does an arrow key walk the prompt ladder, and when does it move the
|
|
3
|
+
* caret?
|
|
4
|
+
*
|
|
5
|
+
* Pure — a function of the buffer, the caret and what recall last wrote — so the
|
|
6
|
+
* whole interaction is testable without an editor. The surface is meant to be
|
|
7
|
+
* swappable (textarea → Tiptap) without retesting the rules.
|
|
8
|
+
*
|
|
9
|
+
* ## The rule, and where it comes from
|
|
10
|
+
*
|
|
11
|
+
* Recall used to require a COMPLETELY empty composer. That made ArrowUp a dead
|
|
12
|
+
* key in any multi-line draft and left the down direction nowhere to go. The
|
|
13
|
+
* shells settled this decades ago with a boundary test, and zsh's
|
|
14
|
+
* `up-line-or-beginning-search` is the cheapest correct form: it asks whether
|
|
15
|
+
* the text LEFT OF THE CARET contains a newline rather than computing a row
|
|
16
|
+
* index. That is `atFirstLine`.
|
|
17
|
+
*
|
|
18
|
+
* ## The soft-wrap limit, stated rather than discovered
|
|
19
|
+
*
|
|
20
|
+
* One long line that WRAPS across three visual rows is ONE logical line here, so
|
|
21
|
+
* ArrowUp navigates instead of moving up a visual row. Doing better needs
|
|
22
|
+
* `coordsAtPos` pixel geometry and a live DOM, which this seam does not have —
|
|
23
|
+
* and a second editor-specific path is a worse trade than a named limit.
|
|
24
|
+
*
|
|
25
|
+
* Not hypothetical: it is the live complaint against Claude Code
|
|
26
|
+
* (anthropics/claude-code#63670), whose docs promise a visual-row rule its
|
|
27
|
+
* shipped build does not implement. We promise the logical-line rule and
|
|
28
|
+
* implement exactly that, and the story says so.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Caret position within the composer buffer.
|
|
33
|
+
*
|
|
34
|
+
* A collapsed caret has `start === end`. A selection never navigates.
|
|
35
|
+
*/
|
|
36
|
+
export interface ComposerCaret {
|
|
37
|
+
readonly start: number;
|
|
38
|
+
readonly end: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when nothing in the buffer sits above the caret — the top of the ladder.
|
|
43
|
+
*
|
|
44
|
+
* A selection is never a boundary: Up with text selected means "extend or
|
|
45
|
+
* collapse", and hijacking it destroys a selection the user is building.
|
|
46
|
+
* DevTools takes the same position.
|
|
47
|
+
*/
|
|
48
|
+
export function atFirstLine(value: string, caret: ComposerCaret | null): boolean {
|
|
49
|
+
if (value === "") return true;
|
|
50
|
+
if (caret === null || caret.start !== caret.end) return false;
|
|
51
|
+
return !value.slice(0, caret.start).includes("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when nothing in the buffer sits below the caret — the bottom rung. */
|
|
55
|
+
export function atLastLine(value: string, caret: ComposerCaret | null): boolean {
|
|
56
|
+
if (value === "") return true;
|
|
57
|
+
if (caret === null || caret.start !== caret.end) return false;
|
|
58
|
+
return !value.slice(caret.end).includes("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* May an arrow press navigate at all, whatever the caret says?
|
|
63
|
+
*
|
|
64
|
+
* Only while the buffer is still recall's own: empty, or byte-identical to what
|
|
65
|
+
* recall last wrote. The moment the user types a character the buffer is theirs
|
|
66
|
+
* and arrows only move the caret.
|
|
67
|
+
*
|
|
68
|
+
* Taken from Codex's gate, and it has a second consequence worth naming: a
|
|
69
|
+
* non-empty buffer never navigates, so there is no buffer for a prefix filter to
|
|
70
|
+
* read. zsh/fish-style prefix search and this rule are mutually exclusive by
|
|
71
|
+
* construction — prefix search is off the table until something like `Ctrl+R`
|
|
72
|
+
* is wired instead.
|
|
73
|
+
*/
|
|
74
|
+
export function bufferIsRecallOwned(value: string, lastRecalled: string | null): boolean {
|
|
75
|
+
if (value === "") return true;
|
|
76
|
+
return lastRecalled !== null && value === lastRecalled;
|
|
77
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sent` rungs of the recall ladder, read out of a room's transcript.
|
|
3
|
+
*
|
|
4
|
+
* `ladder.ts` WALKS the ladder; this builds the one array it walks. Split
|
|
5
|
+
* because the walk is about keystrokes and this is about the room: they change
|
|
6
|
+
* for unrelated reasons and neither needs the other to be tested.
|
|
7
|
+
*
|
|
8
|
+
* Derived from the transcript, never cached locally. The server is the SSoT for
|
|
9
|
+
* "what did I send here", so the ladder matches what is on screen and works from
|
|
10
|
+
* any browser or device — where a local copy is blind, and where it would also
|
|
11
|
+
* outlive the room it belongs to.
|
|
12
|
+
*
|
|
13
|
+
* A localStorage cache did exist, to cover the moment before history arrives. It
|
|
14
|
+
* went with the composer that consumed it: the surviving surface seeds from the
|
|
15
|
+
* transcript it has already loaded, so the cache only ever had a wrong answer to
|
|
16
|
+
* offer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Entries kept per room. Long enough to reach yesterday's prompt, short enough
|
|
20
|
+
* that the whole ladder stays walkable. */
|
|
21
|
+
const HISTORY_LIMIT = 50;
|
|
22
|
+
|
|
23
|
+
/** A single oversized paste must not evict the entire ladder. */
|
|
24
|
+
const MAX_ENTRY_LENGTH = 8_000;
|
|
25
|
+
|
|
26
|
+
/** The slice of a chat message the ladder cares about. */
|
|
27
|
+
interface LadderSource {
|
|
28
|
+
role: string;
|
|
29
|
+
content: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The room's user-authored prompts, oldest first.
|
|
34
|
+
*
|
|
35
|
+
* Immediate repeats collapse so a double-send does not burn two ArrowUp steps.
|
|
36
|
+
*/
|
|
37
|
+
export function ladderFromMessages(messages: readonly LadderSource[]): string[] {
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
for (const m of messages) {
|
|
40
|
+
if (m.role !== "user") continue;
|
|
41
|
+
const text = m.content.trim();
|
|
42
|
+
if (!text || text.length > MAX_ENTRY_LENGTH) continue;
|
|
43
|
+
if (out[out.length - 1] === text) continue;
|
|
44
|
+
out.push(text);
|
|
45
|
+
}
|
|
46
|
+
return out.slice(-HISTORY_LIMIT);
|
|
47
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ↑/↓ prompt recall: the boundary rules and the ladder they walk.
|
|
2
|
+
//
|
|
3
|
+
// Pure by design — the surface is meant to be swappable without retesting any
|
|
4
|
+
// of this. May import: nothing.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
atFirstLine,
|
|
8
|
+
atLastLine,
|
|
9
|
+
bufferIsRecallOwned,
|
|
10
|
+
type ComposerCaret,
|
|
11
|
+
} from "./boundary";
|
|
12
|
+
export {
|
|
13
|
+
REST,
|
|
14
|
+
recallNext,
|
|
15
|
+
recallPrevious,
|
|
16
|
+
type RecallLadder,
|
|
17
|
+
type RecallMove,
|
|
18
|
+
type RecallPosition,
|
|
19
|
+
type RecallState,
|
|
20
|
+
} from "./ladder";
|
|
21
|
+
// Where the `sent` rungs come FROM. `ladder.ts` walks the ladder; this builds
|
|
22
|
+
// the one array it walks, out of the transcript rather than a local cache.
|
|
23
|
+
export { ladderFromMessages } from "./from-transcript";
|