@guuey/chat 0.4.0
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 +137 -0
- package/dist/history-inputs.d.ts +24 -0
- package/dist/history-inputs.d.ts.map +1 -0
- package/dist/history-inputs.js +34 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/plan.d.ts +5 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +629 -0
- package/dist/policy.d.ts +110 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +75 -0
- package/dist/react/components.d.ts +87 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +270 -0
- package/dist/react/guuey-chat.d.ts +84 -0
- package/dist/react/guuey-chat.d.ts.map +1 -0
- package/dist/react/guuey-chat.js +103 -0
- package/dist/react/markdown.d.ts +32 -0
- package/dist/react/markdown.d.ts.map +1 -0
- package/dist/react/markdown.js +40 -0
- package/dist/react/theme-css.d.ts +16 -0
- package/dist/react/theme-css.d.ts.map +1 -0
- package/dist/react/theme-css.js +37 -0
- package/dist/react/transcript.d.ts +42 -0
- package/dist/react/transcript.d.ts.map +1 -0
- package/dist/react/transcript.js +88 -0
- package/dist/react/use-transcript.d.ts +39 -0
- package/dist/react/use-transcript.d.ts.map +1 -0
- package/dist/react/use-transcript.js +201 -0
- package/dist/react.d.ts +21 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +20 -0
- package/dist/strings.d.ts +74 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +45 -0
- package/dist/theme.d.ts +99 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +182 -0
- package/dist/types.d.ts +283 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +87 -0
- package/src/corpus/README.md +40 -0
- package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
- package/src/corpus/capture.ts +67 -0
- package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
- package/src/corpus/drive.ts +184 -0
- package/src/corpus/fixtures.ts +338 -0
- package/src/history-inputs.ts +48 -0
- package/src/index.ts +58 -0
- package/src/plan.ts +740 -0
- package/src/policy.ts +146 -0
- package/src/react/components.tsx +655 -0
- package/src/react/guuey-chat.tsx +227 -0
- package/src/react/markdown.tsx +114 -0
- package/src/react/theme-css.ts +50 -0
- package/src/react/transcript.tsx +187 -0
- package/src/react/use-transcript.ts +274 -0
- package/src/react.tsx +51 -0
- package/src/strings.ts +144 -0
- package/src/theme.ts +195 -0
- package/src/types.ts +320 -0
- package/styles.css +514 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<GuueyChat>` — the batteries-included surface (guuey#135 wave 3c): the
|
|
3
|
+
* whole tier stack wired end to end, PLUS the composer (which arrives here
|
|
4
|
+
* per the founder ruling — earlier tiers are transcript-only).
|
|
5
|
+
*
|
|
6
|
+
* useAgentInvoke → useTranscriptInputs → useTranscript → <Transcript>
|
|
7
|
+
* + the composer
|
|
8
|
+
*
|
|
9
|
+
* It is a THIN composition of the exported tiers — every wire below is a
|
|
10
|
+
* public API, so a builder ejects one level down (own composer around
|
|
11
|
+
* `<Transcript>`, own renderer over `planTranscript`, own everything over
|
|
12
|
+
* `invokeTurn`) without a cliff. Every prop beyond the connection
|
|
13
|
+
* essentials is optional.
|
|
14
|
+
*
|
|
15
|
+
* ## Composer state matrix
|
|
16
|
+
*
|
|
17
|
+
* - `endpointUrl === null` → input disabled, `composerUnavailable`
|
|
18
|
+
* placeholder (chat cannot exist).
|
|
19
|
+
* - idle (`status === "ready"`) → input enabled; **Send** enabled iff the
|
|
20
|
+
* input has non-whitespace text.
|
|
21
|
+
* - in flight (any other status, cold-start waits included — R12's status
|
|
22
|
+
* line owns the WHY) → input stays enabled (type the next message while
|
|
23
|
+
* the agent works), Send is replaced by **Stop**, which aborts the turn
|
|
24
|
+
* (partial text kept, "Stopped." marked — the hook's contract).
|
|
25
|
+
* - Enter sends; Shift+Enter inserts a newline; an IME-composing Enter
|
|
26
|
+
* never sends (the candidate commit is not a submit).
|
|
27
|
+
*
|
|
28
|
+
* ## History
|
|
29
|
+
*
|
|
30
|
+
* Thread rehydration is the HOOK's mechanics: give `adapters` a `history`
|
|
31
|
+
* adapter (e.g. `createWebAdapters({ apiBaseUrl, … })`) and a persisted
|
|
32
|
+
* threadId rehydrates on mount — text transcript + persisted cards, which
|
|
33
|
+
* mount through the same R6 path as live views. Nothing here to configure.
|
|
34
|
+
*/
|
|
35
|
+
import { useCallback, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
|
36
|
+
import { createWebAdapters, type AgentInvokeAdapters } from "@guuey/agent-client";
|
|
37
|
+
import { useAgentInvoke } from "@guuey/agent-client/react";
|
|
38
|
+
import type { UiResourceReader } from "@guuey/mcp-apps-host";
|
|
39
|
+
import { calmPolicy, debugPolicy, type TranscriptPolicy } from "../policy.js";
|
|
40
|
+
import { defaultChatStrings, type ChatStrings } from "../strings.js";
|
|
41
|
+
import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
|
|
42
|
+
import type { ErrorItem, PromptItem, UserMessageItem } from "../types.js";
|
|
43
|
+
import type { ThemeMode } from "./theme-css.js";
|
|
44
|
+
import { Transcript, type TranscriptWindowing } from "./transcript.js";
|
|
45
|
+
import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
|
|
46
|
+
import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
|
|
47
|
+
|
|
48
|
+
export interface GuueyChatProps {
|
|
49
|
+
/** Pod base URL (with or without `/agent/invoke`). `null` disables chat. */
|
|
50
|
+
endpointUrl: string | null;
|
|
51
|
+
/** Owning app id — namespaces the persisted threadId. */
|
|
52
|
+
appId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Host couplings (storage / id / transport / history). Default:
|
|
55
|
+
* `createWebAdapters()` — localStorage thread persistence + the web SSE
|
|
56
|
+
* transport (cookie/guest identity, saturation + cold-start retries).
|
|
57
|
+
*/
|
|
58
|
+
adapters?: AgentInvokeAdapters;
|
|
59
|
+
/** Policy preset (spec §5). Default `"calm"`. */
|
|
60
|
+
preset?: "calm" | "debug";
|
|
61
|
+
/** Knob overrides applied on top of the preset (spec §3's columns). */
|
|
62
|
+
policy?: Partial<TranscriptPolicy>;
|
|
63
|
+
/** Per-slot component overrides (spec §3's override column). */
|
|
64
|
+
components?: Partial<TranscriptComponents>;
|
|
65
|
+
/** String overrides — merged over the preset's `ChatStrings` (§4.2). */
|
|
66
|
+
strings?: Partial<ChatStrings>;
|
|
67
|
+
theme?: GuueyChatTheme;
|
|
68
|
+
mode?: ThemeMode;
|
|
69
|
+
/** DOM windowing (§3.2). `false` renders everything. */
|
|
70
|
+
window?: TranscriptWindowing | false;
|
|
71
|
+
/** R6 locator resolution (history cards). See `useTranscript`. */
|
|
72
|
+
reader?: UiResourceReader;
|
|
73
|
+
/** R6 pass-through (relay hook, sandbox page/flags, host context…). */
|
|
74
|
+
viewProps?: TranscriptItemContext["viewProps"];
|
|
75
|
+
/**
|
|
76
|
+
* R10: what accept/decline actually DO (the grant channel is the host's).
|
|
77
|
+
* The transcript record moves regardless; without a handler the prompt
|
|
78
|
+
* card is record-only.
|
|
79
|
+
*/
|
|
80
|
+
onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
|
|
81
|
+
/** R11 action slot (sign-in / retry affordances). */
|
|
82
|
+
onErrorAction?: (item: ErrorItem) => void;
|
|
83
|
+
className?: string;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PROMPT_STATE = {
|
|
88
|
+
accept: "answered",
|
|
89
|
+
decline: "declined",
|
|
90
|
+
dismiss: "dismissed",
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
94
|
+
const {
|
|
95
|
+
endpointUrl,
|
|
96
|
+
appId,
|
|
97
|
+
adapters: adaptersProp,
|
|
98
|
+
preset = "calm",
|
|
99
|
+
policy: policyOverrides,
|
|
100
|
+
components,
|
|
101
|
+
strings: stringOverrides,
|
|
102
|
+
theme = DEFAULT_CHAT_THEME,
|
|
103
|
+
mode = "light",
|
|
104
|
+
window: windowing,
|
|
105
|
+
reader,
|
|
106
|
+
viewProps,
|
|
107
|
+
onPromptAction,
|
|
108
|
+
onErrorAction,
|
|
109
|
+
className,
|
|
110
|
+
style,
|
|
111
|
+
} = props;
|
|
112
|
+
|
|
113
|
+
const adapters = useMemo(() => adaptersProp ?? createWebAdapters(), [adaptersProp]);
|
|
114
|
+
const invoke = useAgentInvoke({ endpointUrl, ...(appId !== undefined ? { appId } : {}), adapters, preserveBlocks: true });
|
|
115
|
+
|
|
116
|
+
const policy = useMemo(() => {
|
|
117
|
+
const factory = preset === "debug" ? debugPolicy : calmPolicy;
|
|
118
|
+
const strings: ChatStrings = {
|
|
119
|
+
...defaultChatStrings,
|
|
120
|
+
...policyOverrides?.strings,
|
|
121
|
+
...stringOverrides,
|
|
122
|
+
};
|
|
123
|
+
return factory({ ...policyOverrides, strings });
|
|
124
|
+
}, [preset, policyOverrides, stringOverrides]);
|
|
125
|
+
|
|
126
|
+
const { inputs, resolvePrompt } = useTranscriptInputs(invoke);
|
|
127
|
+
const { plan, toggle, resolvedMounts, onViewPhase } = useTranscript({
|
|
128
|
+
inputs,
|
|
129
|
+
policy,
|
|
130
|
+
...(reader !== undefined ? { reader } : {}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Composer ─────────────────────────────────────────────────────────
|
|
134
|
+
const [input, setInput] = useState("");
|
|
135
|
+
const busy = invoke.status !== "ready";
|
|
136
|
+
const available = endpointUrl !== null;
|
|
137
|
+
const canSend = available && !busy && input.trim() !== "";
|
|
138
|
+
|
|
139
|
+
const submit = useCallback((): void => {
|
|
140
|
+
const text = input.trim();
|
|
141
|
+
if (text === "" || !available || busy) return;
|
|
142
|
+
setInput("");
|
|
143
|
+
void invoke.send(text).catch(() => {
|
|
144
|
+
// The hook surfaces the failure (`error` / R0 failed-send).
|
|
145
|
+
});
|
|
146
|
+
}, [input, available, busy, invoke]);
|
|
147
|
+
|
|
148
|
+
const handleRetry = useCallback(
|
|
149
|
+
(item: UserMessageItem) => {
|
|
150
|
+
void invoke.send(item.text).catch(() => {
|
|
151
|
+
// Same contract as submit: the hook owns failure surfacing.
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
[invoke],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const handlePromptAction = useCallback(
|
|
158
|
+
(item: PromptItem, action: "accept" | "decline" | "dismiss") => {
|
|
159
|
+
resolvePrompt(item.promptId, PROMPT_STATE[action]);
|
|
160
|
+
onPromptAction?.(item, action);
|
|
161
|
+
},
|
|
162
|
+
[resolvePrompt, onPromptAction],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const strings = policy.strings;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={`guuey-chat-surface${className !== undefined ? ` ${className}` : ""}`}
|
|
170
|
+
style={style}
|
|
171
|
+
>
|
|
172
|
+
<Transcript
|
|
173
|
+
plan={plan}
|
|
174
|
+
strings={strings}
|
|
175
|
+
theme={theme}
|
|
176
|
+
mode={mode}
|
|
177
|
+
{...(windowing !== undefined ? { window: windowing } : {})}
|
|
178
|
+
{...(components !== undefined ? { components } : {})}
|
|
179
|
+
onToggle={toggle}
|
|
180
|
+
onRetry={handleRetry}
|
|
181
|
+
onPromptAction={handlePromptAction}
|
|
182
|
+
{...(onErrorAction !== undefined ? { onErrorAction } : {})}
|
|
183
|
+
resolvedMounts={resolvedMounts}
|
|
184
|
+
onViewPhase={onViewPhase}
|
|
185
|
+
{...(viewProps !== undefined ? { viewProps } : {})}
|
|
186
|
+
/>
|
|
187
|
+
<form
|
|
188
|
+
className="guuey-chat-composer"
|
|
189
|
+
onSubmit={(e) => {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
submit();
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<textarea
|
|
195
|
+
className="guuey-chat-composer-input"
|
|
196
|
+
rows={1}
|
|
197
|
+
value={input}
|
|
198
|
+
onChange={(e) => setInput(e.target.value)}
|
|
199
|
+
onKeyDown={(e) => {
|
|
200
|
+
// `isComposing` guards IME input: an Enter that commits a
|
|
201
|
+
// candidate must not also send the message.
|
|
202
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
submit();
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
disabled={!available}
|
|
208
|
+
aria-label={strings.composerLabel}
|
|
209
|
+
placeholder={available ? strings.composerPlaceholder : strings.composerUnavailable}
|
|
210
|
+
/>
|
|
211
|
+
{busy ? (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className="guuey-chat-composer-stop"
|
|
215
|
+
onClick={() => invoke.abort()}
|
|
216
|
+
>
|
|
217
|
+
{strings.stop}
|
|
218
|
+
</button>
|
|
219
|
+
) : (
|
|
220
|
+
<button type="submit" className="guuey-chat-composer-send" disabled={!canSend}>
|
|
221
|
+
{strings.send}
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</form>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The R1 markdown surface — the ONE place `@guuey/chat` renders
|
|
3
|
+
* agent-influenced content in the embedder's origin, and therefore the
|
|
4
|
+
* package's sanitizer boundary (spec §3.1).
|
|
5
|
+
*
|
|
6
|
+
* The pipeline is `@silverprotocol/richtext`: markdown → a TYPED AST in
|
|
7
|
+
* which raw HTML is structurally unrepresentable (no HTML node type
|
|
8
|
+
* exists; angle brackets can only ever be literal text) → React elements.
|
|
9
|
+
* That is deliberately STRONGER than the spec's letter (markdown → HTML →
|
|
10
|
+
* sanitize): there is no HTML string at any point, so there is nothing to
|
|
11
|
+
* sanitize incorrectly — no `dangerouslySetInnerHTML` anywhere in this
|
|
12
|
+
* package.
|
|
13
|
+
*
|
|
14
|
+
* Safety properties, each pinned by a test:
|
|
15
|
+
* - raw HTML (`<img onerror=…>`, `<script>`) renders as literal text;
|
|
16
|
+
* - link `href` is populated upstream ONLY for http/https/mailto —
|
|
17
|
+
* `javascript:`, `data:`, `vbscript:`, and relative targets parse as
|
|
18
|
+
* styled text with NO anchor `href` (richtext's `SAFE_HREF` allowlist);
|
|
19
|
+
* - navigable links carry `rel="noopener noreferrer" target="_blank"`;
|
|
20
|
+
* - there is NO image syntax in the node vocabulary at all — the spec's
|
|
21
|
+
* F5 ruling (markdown images off; R7 media blocks are the sanctioned
|
|
22
|
+
* image path) holds structurally, not by configuration.
|
|
23
|
+
*
|
|
24
|
+
* Streaming-tolerant by upstream design: unclosed emphasis mid-delta
|
|
25
|
+
* renders as formatted-so-far.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReactNode } from "react";
|
|
28
|
+
import {
|
|
29
|
+
parseRichText,
|
|
30
|
+
type RichTextBlock,
|
|
31
|
+
type RichTextInline,
|
|
32
|
+
} from "@silverprotocol/richtext";
|
|
33
|
+
|
|
34
|
+
function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
|
|
35
|
+
return nodes.map((node, i) => {
|
|
36
|
+
switch (node.type) {
|
|
37
|
+
case "text":
|
|
38
|
+
return <span key={i}>{node.text}</span>;
|
|
39
|
+
case "break":
|
|
40
|
+
return <br key={i} />;
|
|
41
|
+
case "strong":
|
|
42
|
+
return (
|
|
43
|
+
<strong key={i}>
|
|
44
|
+
<Inline nodes={node.children} />
|
|
45
|
+
</strong>
|
|
46
|
+
);
|
|
47
|
+
case "em":
|
|
48
|
+
return (
|
|
49
|
+
<em key={i}>
|
|
50
|
+
<Inline nodes={node.children} />
|
|
51
|
+
</em>
|
|
52
|
+
);
|
|
53
|
+
case "code":
|
|
54
|
+
return (
|
|
55
|
+
<code key={i} className="guuey-chat-inline-code">
|
|
56
|
+
{node.code}
|
|
57
|
+
</code>
|
|
58
|
+
);
|
|
59
|
+
case "link":
|
|
60
|
+
return node.href ? (
|
|
61
|
+
<a key={i} href={node.href} target="_blank" rel="noopener noreferrer">
|
|
62
|
+
<Inline nodes={node.children} />
|
|
63
|
+
</a>
|
|
64
|
+
) : (
|
|
65
|
+
// Unsafe or unresolvable target: the styled text, no anchor.
|
|
66
|
+
<span key={i} className="guuey-chat-dead-link">
|
|
67
|
+
<Inline nodes={node.children} />
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function Block({ block }: { block: RichTextBlock }): ReactNode {
|
|
75
|
+
switch (block.type) {
|
|
76
|
+
case "paragraph":
|
|
77
|
+
return (
|
|
78
|
+
<p>
|
|
79
|
+
<Inline nodes={block.children} />
|
|
80
|
+
</p>
|
|
81
|
+
);
|
|
82
|
+
case "heading":
|
|
83
|
+
return (
|
|
84
|
+
<p className={`guuey-chat-heading${block.level <= 3 ? " guuey-chat-heading-major" : ""}`}>
|
|
85
|
+
<Inline nodes={block.children} />
|
|
86
|
+
</p>
|
|
87
|
+
);
|
|
88
|
+
case "code-fence":
|
|
89
|
+
return <pre className="guuey-chat-code-fence">{block.code}</pre>;
|
|
90
|
+
case "list": {
|
|
91
|
+
const Tag = block.ordered ? "ol" : "ul";
|
|
92
|
+
return (
|
|
93
|
+
<Tag {...(block.ordered && block.start !== undefined ? { start: block.start } : {})}>
|
|
94
|
+
{block.items.map((item, j) => (
|
|
95
|
+
<li key={j}>
|
|
96
|
+
<Inline nodes={item.children} />
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</Tag>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sanitized markdown → React elements (see the module docblock). */
|
|
106
|
+
export function Markdown({ text }: { text: string }): ReactNode {
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
{parseRichText(text).map((block, i) => (
|
|
110
|
+
<Block key={i} block={block} />
|
|
111
|
+
))}
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSS projection of `GuueyChatTheme` (spec §6): the schema is the
|
|
3
|
+
* cross-platform contract; this maps one mode's tokens onto the
|
|
4
|
+
* `--guuey-chat-*` custom properties the shipped stylesheet consumes. RN
|
|
5
|
+
* (3c) maps the same object to style values instead — no CSS here is
|
|
6
|
+
* load-bearing for the view-model.
|
|
7
|
+
*/
|
|
8
|
+
import type { GuueyChatTheme } from "../theme.js";
|
|
9
|
+
|
|
10
|
+
export type ThemeMode = "light" | "dark";
|
|
11
|
+
|
|
12
|
+
const RADIUS_PX: Record<GuueyChatTheme["shape"]["radius"], string> = {
|
|
13
|
+
none: "0px",
|
|
14
|
+
soft: "10px",
|
|
15
|
+
round: "18px",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DENSITY_GAP: Record<GuueyChatTheme["shape"]["density"], string> = {
|
|
19
|
+
compact: "6px",
|
|
20
|
+
comfortable: "10px",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One mode's tokens as inline custom properties for the transcript root.
|
|
25
|
+
* Returned as a plain record so callers can spread it into `style` or emit
|
|
26
|
+
* a stylesheet from it.
|
|
27
|
+
*/
|
|
28
|
+
export function themeCssVars(theme: GuueyChatTheme, mode: ThemeMode): Record<string, string> {
|
|
29
|
+
const palette = theme.colors[mode];
|
|
30
|
+
const vars: Record<string, string> = {
|
|
31
|
+
"--guuey-chat-accent": palette.accent,
|
|
32
|
+
"--guuey-chat-on-accent": palette.onAccent,
|
|
33
|
+
"--guuey-chat-ink": palette.ink,
|
|
34
|
+
"--guuey-chat-ink-muted": palette.inkMuted,
|
|
35
|
+
"--guuey-chat-surface": palette.surface,
|
|
36
|
+
"--guuey-chat-canvas": palette.canvas,
|
|
37
|
+
"--guuey-chat-canvas-muted": palette.canvasMuted,
|
|
38
|
+
"--guuey-chat-error": palette.error,
|
|
39
|
+
"--guuey-chat-radius": RADIUS_PX[theme.shape.radius],
|
|
40
|
+
"--guuey-chat-gap": DENSITY_GAP[theme.shape.density],
|
|
41
|
+
"--guuey-chat-scale": String(theme.typography.scale ?? 1),
|
|
42
|
+
};
|
|
43
|
+
if (theme.typography.fontFamily !== undefined) {
|
|
44
|
+
vars["--guuey-chat-font"] = theme.typography.fontFamily;
|
|
45
|
+
}
|
|
46
|
+
if (theme.typography.monoFontFamily !== undefined) {
|
|
47
|
+
vars["--guuey-chat-mono-font"] = theme.typography.monoFontFamily;
|
|
48
|
+
}
|
|
49
|
+
return vars;
|
|
50
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Transcript>` — the plan walker + the §3.2 renderer obligations:
|
|
3
|
+
*
|
|
4
|
+
* - **Scroll contract:** stick-to-bottom while content grows UNLESS the
|
|
5
|
+
* user scrolled up, in which case a "jump to latest" affordance appears
|
|
6
|
+
* instead; anchoring holds through content resize (a ResizeObserver on
|
|
7
|
+
* the item column re-pins — R6 cards growing on `connected` are the
|
|
8
|
+
* canonical breaker). `prefers-reduced-motion` downgrades smooth
|
|
9
|
+
* scrolling to instant.
|
|
10
|
+
* - **Windowing:** long transcripts render the trailing `window.tail`
|
|
11
|
+
* items with a "show earlier" expander — the DOM is capped even though
|
|
12
|
+
* the plan (already O(groups)) carries everything; the plan's stable
|
|
13
|
+
* keys keep expansion state and DOM identity across the window edge.
|
|
14
|
+
* - Theme: the resolved `GuueyChatTheme` is projected as `--guuey-chat-*`
|
|
15
|
+
* custom properties on the root (import `@guuey/chat/styles.css` once
|
|
16
|
+
* for the default look, or restyle the class names entirely).
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
useEffect,
|
|
20
|
+
useLayoutEffect,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
type CSSProperties,
|
|
25
|
+
type ReactNode,
|
|
26
|
+
} from "react";
|
|
27
|
+
import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
|
|
28
|
+
import { defaultChatStrings, type ChatStrings } from "../strings.js";
|
|
29
|
+
import type { TranscriptPlan } from "../types.js";
|
|
30
|
+
import {
|
|
31
|
+
defaultTranscriptComponents,
|
|
32
|
+
renderItem,
|
|
33
|
+
type TranscriptComponents,
|
|
34
|
+
type TranscriptItemContext,
|
|
35
|
+
} from "./components.js";
|
|
36
|
+
import { themeCssVars, type ThemeMode } from "./theme-css.js";
|
|
37
|
+
|
|
38
|
+
/** How close to the bottom (px) still counts as pinned. */
|
|
39
|
+
const PIN_THRESHOLD_PX = 48;
|
|
40
|
+
|
|
41
|
+
export interface TranscriptWindowing {
|
|
42
|
+
/** Trailing items rendered; earlier ones sit behind the expander. */
|
|
43
|
+
tail: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TranscriptProps
|
|
47
|
+
extends Pick<
|
|
48
|
+
TranscriptItemContext,
|
|
49
|
+
"onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "viewProps"
|
|
50
|
+
> {
|
|
51
|
+
plan: TranscriptPlan;
|
|
52
|
+
/** Per-slot component overrides (spec §3's override column). */
|
|
53
|
+
components?: Partial<TranscriptComponents>;
|
|
54
|
+
/** The i18n seam — pass the same strings the policy carries. */
|
|
55
|
+
strings?: ChatStrings;
|
|
56
|
+
theme?: GuueyChatTheme;
|
|
57
|
+
mode?: ThemeMode;
|
|
58
|
+
/** DOM windowing (§3.2). `false` renders everything. Default tail 80. */
|
|
59
|
+
window?: TranscriptWindowing | false;
|
|
60
|
+
className?: string;
|
|
61
|
+
style?: CSSProperties;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function Transcript(props: TranscriptProps): ReactNode {
|
|
65
|
+
const {
|
|
66
|
+
plan,
|
|
67
|
+
components,
|
|
68
|
+
strings = defaultChatStrings,
|
|
69
|
+
theme = DEFAULT_CHAT_THEME,
|
|
70
|
+
mode = "light",
|
|
71
|
+
window: windowing = { tail: 80 },
|
|
72
|
+
className,
|
|
73
|
+
style,
|
|
74
|
+
onToggle,
|
|
75
|
+
onRetry,
|
|
76
|
+
onPromptAction,
|
|
77
|
+
onErrorAction,
|
|
78
|
+
resolvedMounts,
|
|
79
|
+
onViewPhase,
|
|
80
|
+
viewProps,
|
|
81
|
+
} = props;
|
|
82
|
+
|
|
83
|
+
const resolvedComponents: TranscriptComponents = useMemo(
|
|
84
|
+
() => ({ ...defaultTranscriptComponents, ...components }),
|
|
85
|
+
[components],
|
|
86
|
+
);
|
|
87
|
+
const ctx: TranscriptItemContext = useMemo(
|
|
88
|
+
() => ({ strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps }),
|
|
89
|
+
[strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ── Windowing ────────────────────────────────────────────────────────
|
|
93
|
+
const [extraShown, setExtraShown] = useState(0);
|
|
94
|
+
const tail = windowing === false ? Number.POSITIVE_INFINITY : windowing.tail;
|
|
95
|
+
const visibleFrom = Math.max(0, plan.items.length - tail - extraShown);
|
|
96
|
+
const hidden = visibleFrom;
|
|
97
|
+
const visible = plan.items.slice(visibleFrom);
|
|
98
|
+
|
|
99
|
+
// ── Scroll contract ──────────────────────────────────────────────────
|
|
100
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
101
|
+
const column = useRef<HTMLDivElement>(null);
|
|
102
|
+
const pinned = useRef(true);
|
|
103
|
+
const [showJump, setShowJump] = useState(false);
|
|
104
|
+
|
|
105
|
+
const reducedMotion = (): boolean =>
|
|
106
|
+
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
107
|
+
|
|
108
|
+
const scrollToBottom = (smooth: boolean): void => {
|
|
109
|
+
const el = scroller.current;
|
|
110
|
+
if (el === null) return;
|
|
111
|
+
if (typeof el.scrollTo === "function") {
|
|
112
|
+
el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reducedMotion() ? "smooth" : "auto" });
|
|
113
|
+
} else {
|
|
114
|
+
// Environments without Element.scrollTo (jsdom; ancient WebViews).
|
|
115
|
+
el.scrollTop = el.scrollHeight;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const onScroll = (): void => {
|
|
120
|
+
const el = scroller.current;
|
|
121
|
+
if (el === null) return;
|
|
122
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
|
|
123
|
+
pinned.current = nearBottom;
|
|
124
|
+
setShowJump(!nearBottom);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// New content while pinned keeps the bottom in view (before paint, so
|
|
128
|
+
// per-frame streaming updates never visibly jump).
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
if (pinned.current) scrollToBottom(false);
|
|
131
|
+
}, [plan]);
|
|
132
|
+
|
|
133
|
+
// Content RESIZE (an R6 card connecting and growing, an image loading)
|
|
134
|
+
// re-pins too — scrollHeight changes with no plan change.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const el = column.current;
|
|
137
|
+
if (el === null || typeof ResizeObserver === "undefined") return;
|
|
138
|
+
const observer = new ResizeObserver(() => {
|
|
139
|
+
if (pinned.current) scrollToBottom(false);
|
|
140
|
+
});
|
|
141
|
+
observer.observe(el);
|
|
142
|
+
return () => observer.disconnect();
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const rootStyle: CSSProperties = { ...themeCssVars(theme, mode), ...style };
|
|
146
|
+
|
|
147
|
+
const StatusComponent = resolvedComponents.status;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
className={`guuey-chat${className !== undefined ? ` ${className}` : ""}`}
|
|
152
|
+
style={rootStyle}
|
|
153
|
+
data-guuey-chat-mode={mode}
|
|
154
|
+
>
|
|
155
|
+
<div ref={scroller} className="guuey-chat-scroller" onScroll={onScroll}>
|
|
156
|
+
<div ref={column} className="guuey-chat-column">
|
|
157
|
+
{hidden > 0 ? (
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="guuey-chat-show-earlier"
|
|
161
|
+
onClick={() => setExtraShown((n) => n + tail)}
|
|
162
|
+
>
|
|
163
|
+
{strings.showEarlier(hidden)}
|
|
164
|
+
</button>
|
|
165
|
+
) : null}
|
|
166
|
+
{visible.map((item) => renderItem(item, resolvedComponents, ctx))}
|
|
167
|
+
{plan.status !== null ? (
|
|
168
|
+
<StatusComponent item={plan.status} ctx={ctx} />
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
{showJump ? (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
className="guuey-chat-jump"
|
|
176
|
+
onClick={() => {
|
|
177
|
+
pinned.current = true;
|
|
178
|
+
setShowJump(false);
|
|
179
|
+
scrollToBottom(true);
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{strings.jumpToLatest}
|
|
183
|
+
</button>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|