rift-ds 1.9.0 → 1.10.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.
Files changed (79) hide show
  1. package/README.md +3 -3
  2. package/bin/templates/chat/app/api/chat/route.ts +3 -0
  3. package/chat/AssistantTurn.js +5 -2
  4. package/chat/ChatWidget.css +5 -4
  5. package/chat/ChatWidget.d.ts +10 -1
  6. package/chat/ChatWidget.js +17 -4
  7. package/chat/DemoWelcome.js +17 -5
  8. package/chat/FollowupSuggestions.d.ts +7 -4
  9. package/chat/FollowupSuggestions.js +5 -3
  10. package/chat/TurnQuestion.d.ts +19 -0
  11. package/chat/TurnQuestion.js +38 -0
  12. package/chat/demo-script.js +256 -50
  13. package/chat/followups.d.ts +2 -2
  14. package/chat/followups.js +1 -0
  15. package/chat/index.d.ts +2 -1
  16. package/chat/index.js +11 -0
  17. package/chat/queries.d.ts +2 -2
  18. package/chat/queries.js +3 -2
  19. package/chat/questions.d.ts +97 -0
  20. package/chat/questions.js +140 -0
  21. package/chat/registry.json.d.ts +3 -2
  22. package/chat/registry.json.js +1 -1
  23. package/chat/server.d.ts +19 -0
  24. package/chat/server.js +45 -1
  25. package/chat/sim.d.ts +12 -1
  26. package/chat/sim.js +19 -3
  27. package/chat/types.d.ts +58 -1
  28. package/chat/types.js +1 -0
  29. package/chat/useChat.d.ts +16 -1
  30. package/chat/useChat.js +39 -2
  31. package/components/AlertDialog/AlertDialog.css +1 -1
  32. package/components/AnchorNav/AnchorNav.css +1 -1
  33. package/components/AppSidebar/AppSidebar.css +5 -5
  34. package/components/AppSidebar/AppSidebar.js +3 -2
  35. package/components/Attachment/Attachment.css +3 -1
  36. package/components/BottomNav/BottomNav.css +1 -1
  37. package/components/Chart/Chart.css +1 -0
  38. package/components/ChatThread/ChatThread.css +21 -0
  39. package/components/ChatThread/ChatThread.d.ts +12 -1
  40. package/components/ChatThread/ChatThread.js +40 -1
  41. package/components/ColorPicker/ColorPicker.css +1 -1
  42. package/components/Combobox/Combobox.css +1 -1
  43. package/components/CommandPalette/CommandPalette.css +1 -1
  44. package/components/Composer/Composer.css +1 -1
  45. package/components/ContextMenu/ContextMenu.css +1 -1
  46. package/components/Dialog/Dialog.css +1 -1
  47. package/components/Drawer/Drawer.css +1 -1
  48. package/components/Dropdown/Dropdown.css +1 -1
  49. package/components/DropdownMenu/DropdownMenu.css +1 -1
  50. package/components/FilterBar/FilterBar.css +1 -1
  51. package/components/HoverCard/HoverCard.css +1 -1
  52. package/components/Lightbox/Lightbox.css +1 -1
  53. package/components/ModelPicker/ModelPicker.css +1 -1
  54. package/components/Popover/Popover.css +1 -1
  55. package/components/PromptSuggestions/PromptSuggestions.css +67 -0
  56. package/components/PromptSuggestions/PromptSuggestions.d.ts +8 -0
  57. package/components/PromptSuggestions/PromptSuggestions.js +34 -26
  58. package/components/QuestionCard/QuestionCard.css +358 -0
  59. package/components/QuestionCard/QuestionCard.d.ts +136 -0
  60. package/components/QuestionCard/QuestionCard.js +371 -0
  61. package/components/RichDropdown/RichDropdown.css +1 -1
  62. package/components/SourceChip/SourceChip.css +1 -1
  63. package/components/TimePicker/TimePicker.css +1 -1
  64. package/components/Toast/Toast.css +1 -1
  65. package/components/Tooltip/Tooltip.css +1 -1
  66. package/components/TopAppBar/TopAppBar.css +1 -1
  67. package/components/registry.json +8 -0
  68. package/components/registry.json.d.ts +8 -0
  69. package/components/registry.json.js +1 -1
  70. package/index.d.ts +1 -0
  71. package/index.js +3 -0
  72. package/package.json +5 -1
  73. package/tokens/breakpoints.d.ts +60 -0
  74. package/tokens/breakpoints.js +92 -0
  75. package/tokens/registry.d.ts +1 -1
  76. package/tokens/registry.json +14 -0
  77. package/tokens/registry.json.d.ts +14 -0
  78. package/tokens/registry.json.js +1 -1
  79. package/tokens/tokens-light.css +22 -0
@@ -0,0 +1,140 @@
1
+ import { SUGGESTION_MAX_CHARS, fitsChip } from "./suggestions.js";
2
+ const MAX_QUESTIONS = 4;
3
+ const MAX_QUESTION_OPTIONS = 5;
4
+ const MIN_QUESTION_OPTIONS = 2;
5
+ const QUESTION_PROMPT_MAX_CHARS = 60;
6
+ const OPTION_DESCRIPTION_MAX_CHARS = 80;
7
+ const str = (value) => typeof value === "string" ? value.trim() : "";
8
+ const slug = (text, index) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || `option-${index + 1}`;
9
+ function sanitizeQuestion(input, index) {
10
+ if (typeof input !== "object" || input === null) return null;
11
+ const raw = input;
12
+ const prompt = str(raw.prompt);
13
+ if (!prompt || prompt.length > QUESTION_PROMPT_MAX_CHARS) return null;
14
+ const mode = raw.mode === "multi" ? "multi" : "single";
15
+ if (!Array.isArray(raw.options)) return null;
16
+ const options = [];
17
+ const seen = /* @__PURE__ */ new Set();
18
+ for (const [optionIndex, entry] of raw.options.entries()) {
19
+ if (typeof entry !== "object" || entry === null) return null;
20
+ const option = entry;
21
+ const label = str(option.label);
22
+ if (!fitsChip(label)) return null;
23
+ const value = str(option.value) || slug(label, optionIndex);
24
+ if (seen.has(value) || seen.has(label.toLowerCase())) return null;
25
+ seen.add(value);
26
+ seen.add(label.toLowerCase());
27
+ const description = str(option.description);
28
+ options.push(
29
+ description && description.length <= OPTION_DESCRIPTION_MAX_CHARS ? { value, label, description } : { value, label }
30
+ );
31
+ }
32
+ if (options.length < MIN_QUESTION_OPTIONS || options.length > MAX_QUESTION_OPTIONS) return null;
33
+ const question = {
34
+ id: str(raw.id) || `q${index + 1}`,
35
+ prompt,
36
+ mode,
37
+ options
38
+ };
39
+ if (raw.other === true) question.other = true;
40
+ if (mode === "multi") {
41
+ const available = options.length + (question.other ? 1 : 0);
42
+ const min = typeof raw.min === "number" ? Math.floor(raw.min) : 1;
43
+ const max = typeof raw.max === "number" ? Math.floor(raw.max) : available;
44
+ if (min < 1 || max < min || max > available) return null;
45
+ if (min !== 1) question.min = min;
46
+ if (max !== available) question.max = max;
47
+ }
48
+ return question;
49
+ }
50
+ function sanitizeQuestionSet(input, fallbackId = "questions") {
51
+ if (typeof input !== "object" || input === null) return null;
52
+ const raw = input;
53
+ if (!Array.isArray(raw.questions) || raw.questions.length > MAX_QUESTIONS) return null;
54
+ const questions = raw.questions.map((question, index) => sanitizeQuestion(question, index)).filter((question) => question !== null);
55
+ if (questions.length === 0) return null;
56
+ if (new Set(questions.map((question) => question.id)).size !== questions.length) return null;
57
+ return { id: str(raw.id) || fallbackId, questions };
58
+ }
59
+ function isBlockingQuestion(turn, live) {
60
+ return !live && turn?.role === "assistant" && !!turn.question && turn.question.questions.length > 1 && turn.answers === void 0;
61
+ }
62
+ function describeAnswers(set, answers) {
63
+ return {
64
+ items: set.questions.map((question) => {
65
+ const answer = answers.find((entry) => entry.questionId === question.id);
66
+ const labels = question.options.filter((option) => answer?.values.includes(option.value)).map((option) => option.label);
67
+ if (answer?.other) labels.push(answer.other);
68
+ return { prompt: question.prompt, answers: labels };
69
+ })
70
+ };
71
+ }
72
+ function serializeReply(reply) {
73
+ const answered = (labels) => labels.length > 0 ? labels.join(", ") : "(no answer)";
74
+ if (reply.items.length === 1) return answered(reply.items[0].answers);
75
+ return reply.items.map((item) => `${item.prompt} ${answered(item.answers)}`).join("\n");
76
+ }
77
+ const ASK_USER_INPUT_SCHEMA = {
78
+ type: "object",
79
+ properties: {
80
+ questions: {
81
+ type: "array",
82
+ minItems: 1,
83
+ maxItems: MAX_QUESTIONS,
84
+ items: {
85
+ type: "object",
86
+ properties: {
87
+ id: { type: "string", description: 'A short stable key for the question, e.g. "framework".' },
88
+ prompt: {
89
+ type: "string",
90
+ description: `The question in a few words, at most ${QUESTION_PROMPT_MAX_CHARS} characters, e.g. "Which framework?".`
91
+ },
92
+ mode: {
93
+ type: "string",
94
+ enum: ["single", "multi"],
95
+ description: "single when exactly one answer fits; multi when several can be true at once."
96
+ },
97
+ options: {
98
+ type: "array",
99
+ minItems: MIN_QUESTION_OPTIONS,
100
+ maxItems: MAX_QUESTION_OPTIONS,
101
+ items: {
102
+ type: "object",
103
+ properties: {
104
+ label: {
105
+ type: "string",
106
+ description: `The answer as the person would say it, at most ${SUGGESTION_MAX_CHARS} characters.`
107
+ },
108
+ description: {
109
+ type: "string",
110
+ description: `Optional: what choosing it means, at most ${OPTION_DESCRIPTION_MAX_CHARS} characters.`
111
+ }
112
+ },
113
+ required: ["label"]
114
+ }
115
+ },
116
+ other: {
117
+ type: "boolean",
118
+ description: 'Offer a free-text "Something else" when the options may not cover every answer.'
119
+ },
120
+ min: { type: "integer", description: "multi only: the fewest picks that make an answer." },
121
+ max: { type: "integer", description: "multi only: the most picks allowed." }
122
+ },
123
+ required: ["prompt", "options"]
124
+ }
125
+ }
126
+ },
127
+ required: ["questions"]
128
+ };
129
+ export {
130
+ ASK_USER_INPUT_SCHEMA,
131
+ MAX_QUESTIONS,
132
+ MAX_QUESTION_OPTIONS,
133
+ MIN_QUESTION_OPTIONS,
134
+ OPTION_DESCRIPTION_MAX_CHARS,
135
+ QUESTION_PROMPT_MAX_CHARS,
136
+ describeAnswers,
137
+ isBlockingQuestion,
138
+ sanitizeQuestionSet,
139
+ serializeReply
140
+ };
@@ -6,11 +6,11 @@ declare const _default: {
6
6
  "slug": "ai-chat",
7
7
  "label": "AI chat",
8
8
  "category": "ai",
9
- "description": "The whole AI chat, assembled: the panel, the streaming replies, the history, the cards a reply can carry and the server handlers, set up with props.",
9
+ "description": "The whole AI chat: the panel, the streaming replies, the history, the cards and questions a reply can carry and the server handlers, set up with props.",
10
10
  "command": "init chat",
11
11
  "subpaths": {
12
12
  "./chat": "The widget, the provider, the launcher and the engine under them.",
13
- "./chat/server": "The streaming chat handler and its follow-ups and feedback siblings, for any web-standard route.",
13
+ "./chat/server": "The streaming chat handler, with an opt-in tool for asking the visitor questions, and its follow-ups and feedback siblings, for any web-standard route.",
14
14
  "./chat/upstash": "Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.",
15
15
  "./chat/demo": "A scripted conversation with rich cards and fixtures, for a preview that needs no key.",
16
16
  "./chat/chat.css": "The widget's stylesheet."
@@ -52,6 +52,7 @@ declare const _default: {
52
52
  "PixelAvatar",
53
53
  "PromptSuggestions",
54
54
  "Prose",
55
+ "QuestionCard",
55
56
  "Reasoning",
56
57
  "ThreadPanel",
57
58
  "ThreadTabs"
@@ -1,5 +1,5 @@
1
1
  const $comment = "The widget registry: the fourth kind of thing the package ships, between the components and the templates. One member by design. `parts` names the library components the widget composes (held to src/components/registry.json by scripts/validate-chat-widget.mjs), `subpaths` the package exports it occupies (held to scripts/package-manifest.mjs), `category` the component category whose section and sidebar group its page joins (held to src/components/registry.json), `peers` the optional peer dependencies each subpath needs (held to package.json). The widget's page under /components, the /docs/chat page, the chat corpus and the MCP tools read this file; never type its facts elsewhere.";
2
- const widgets = [{ "name": "ChatWidget", "slug": "ai-chat", "label": "AI chat", "category": "ai", "description": "The whole AI chat, assembled: the panel, the streaming replies, the history, the cards a reply can carry and the server handlers, set up with props.", "command": "init chat", "subpaths": { "./chat": "The widget, the provider, the launcher and the engine under them.", "./chat/server": "The streaming chat handler and its follow-ups and feedback siblings, for any web-standard route.", "./chat/upstash": "Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.", "./chat/demo": "A scripted conversation with rich cards and fixtures, for a preview that needs no key.", "./chat/chat.css": "The widget's stylesheet." }, "peers": { "./chat": ["react-markdown", "remark-gfm"], "./chat/server": ["@anthropic-ai/sdk"], "./chat/upstash": ["@upstash/ratelimit", "@upstash/redis"], "./chat/demo": ["recharts"] }, "parts": ["AgentRail", "AgentStatus", "AiButton", "ArtifactCard", "ArtifactPanel", "AttachmentDropZone", "AttachmentGroup", "ChatHeader", "ChatMessage", "ChatThread", "CircularButton", "CodeBlock", "Composer", "DropdownMenu", "EmptyState", "Mention", "ModelPicker", "PixelAvatar", "PromptSuggestions", "Prose", "Reasoning", "ThreadPanel", "ThreadTabs"], "documented": ["ChatWidget", "ChatProvider", "ChatLauncher", "ChatThreads", "ChatTabs", "ChatAgentPane", "ChatArtifactPane", "ChatArtifactCard"] }];
2
+ const widgets = [{ "name": "ChatWidget", "slug": "ai-chat", "label": "AI chat", "category": "ai", "description": "The whole AI chat: the panel, the streaming replies, the history, the cards and questions a reply can carry and the server handlers, set up with props.", "command": "init chat", "subpaths": { "./chat": "The widget, the provider, the launcher and the engine under them.", "./chat/server": "The streaming chat handler, with an opt-in tool for asking the visitor questions, and its follow-ups and feedback siblings, for any web-standard route.", "./chat/upstash": "Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.", "./chat/demo": "A scripted conversation with rich cards and fixtures, for a preview that needs no key.", "./chat/chat.css": "The widget's stylesheet." }, "peers": { "./chat": ["react-markdown", "remark-gfm"], "./chat/server": ["@anthropic-ai/sdk"], "./chat/upstash": ["@upstash/ratelimit", "@upstash/redis"], "./chat/demo": ["recharts"] }, "parts": ["AgentRail", "AgentStatus", "AiButton", "ArtifactCard", "ArtifactPanel", "AttachmentDropZone", "AttachmentGroup", "ChatHeader", "ChatMessage", "ChatThread", "CircularButton", "CodeBlock", "Composer", "DropdownMenu", "EmptyState", "Mention", "ModelPicker", "PixelAvatar", "PromptSuggestions", "Prose", "QuestionCard", "Reasoning", "ThreadPanel", "ThreadTabs"], "documented": ["ChatWidget", "ChatProvider", "ChatLauncher", "ChatThreads", "ChatTabs", "ChatAgentPane", "ChatArtifactPane", "ChatArtifactCard"] }];
3
3
  const registry = {
4
4
  $comment,
5
5
  widgets
package/chat/server.d.ts CHANGED
@@ -16,6 +16,13 @@ export interface ChatTool {
16
16
  /** The trace point shown while the round runs, e.g. "Checked the Button prop contract". */
17
17
  tracePoint?: (input: Record<string, unknown>) => string;
18
18
  }
19
+ /** The name of the built-in tool the model asks the visitor questions through. */
20
+ export declare const ASK_USER_TOOL_NAME = "ask_user";
21
+ /**
22
+ * What the model is told about asking. Lives in the tool's description, not
23
+ * the system prompt, so switching it on never disturbs a cached prefix.
24
+ */
25
+ export declare const DEFAULT_ASK_USER_DESCRIPTION: string;
19
26
  /**
20
27
  * How much of the day's budget remains, as a model policy: `open` means the
21
28
  * default model serves, `reduced` means the budget model is the default (the
@@ -105,6 +112,8 @@ export interface ChatHandlerCopy {
105
112
  thinking: string;
106
113
  /** The status label while a tool round runs. */
107
114
  lookingUp: string;
115
+ /** The reply text when the model asks several questions without writing any. One question stands in for itself. */
116
+ questionsIntro: string;
108
117
  /** The error for a busy or overloaded model. */
109
118
  busy: string;
110
119
  /** The error for a rejected key. */
@@ -140,6 +149,16 @@ export interface ChatHandlerOptions {
140
149
  /** The wire value that serves once the budget steps down, and the only one past the lock. Defaults to `defaultModel`. */
141
150
  budgetModel?: string;
142
151
  tools?: ChatTool[];
152
+ /**
153
+ * Give the model the built-in `ask_user` tool, through which a reply can
154
+ * end in questions the widget draws as answers to pick. Pass an object to
155
+ * replace the guidance the model reads. Off by default. Calling it ends
156
+ * the exchange: no further model call is made, so it never counts against
157
+ * `maxModelCalls` beyond the pass that asked.
158
+ */
159
+ askUser?: boolean | {
160
+ description?: string;
161
+ };
143
162
  /** Model calls per exchange: the first pass plus tool rounds. The last allowed pass withholds the tools. */
144
163
  maxModelCalls?: number;
145
164
  /** Caps thinking and response text together. */
package/chat/server.js CHANGED
@@ -1,5 +1,15 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
+ import { ASK_USER_INPUT_SCHEMA, sanitizeQuestionSet } from "./questions.js";
2
3
  import { SUGGESTION_MAX_CHARS, fitsChip } from "./suggestions.js";
4
+ const ASK_USER_TOOL_NAME = "ask_user";
5
+ const DEFAULT_ASK_USER_DESCRIPTION = [
6
+ "Offer the person answers to pick for a question your reply has just asked in words.",
7
+ "Use it only when the most useful next step depends on their answer and the likely answers can be listed: which framework, which of these options, which parts apply.",
8
+ "Do not use it for open questions, for confirming something they already said, or when you can simply answer. Most replies should not ask.",
9
+ "Write the question in your reply first, then call this once, as your last action. The conversation waits for their answer.",
10
+ "Prefer one question. Use several (at most 4) only when you need all the answers before you can continue. mode is single when one answer fits and multi when several can be true together.",
11
+ "Set other to true when the options may not cover every answer."
12
+ ].join(" ");
3
13
  const DEFAULT_CHAT_LIMITS = {
4
14
  maxTurns: 10,
5
15
  maxMessageChars: 4e3,
@@ -15,6 +25,7 @@ const DEFAULT_CHAT_COPY = {
15
25
  reading: "Reading",
16
26
  thinking: "Thinking",
17
27
  lookingUp: "Looking it up",
28
+ questionsIntro: "A few questions before I go on.",
18
29
  busy: "The model is busy right now. Give it a moment and ask again.",
19
30
  unauthorised: "This chat is not configured correctly at the moment.",
20
31
  failed: "Something went wrong reaching the model. Try that again in a moment."
@@ -121,7 +132,15 @@ function createChatHandler(options) {
121
132
  const copy = { ...DEFAULT_CHAT_COPY, ...options.copy };
122
133
  const sanitise = options.sanitiseContext ?? sanitisePath;
123
134
  const tools = options.tools ?? [];
124
- const toolDefinitions = tools.map((tool) => tool.definition);
135
+ const askUserDefinition = options.askUser ? {
136
+ name: ASK_USER_TOOL_NAME,
137
+ description: (typeof options.askUser === "object" ? options.askUser.description : void 0) ?? DEFAULT_ASK_USER_DESCRIPTION,
138
+ input_schema: ASK_USER_INPUT_SCHEMA
139
+ } : null;
140
+ const toolDefinitions = [
141
+ ...tools.map((tool) => tool.definition),
142
+ ...askUserDefinition ? [askUserDefinition] : []
143
+ ];
125
144
  const toolByName = new Map(tools.map((tool) => [tool.definition.name, tool]));
126
145
  const maxModelCalls = options.maxModelCalls ?? 3;
127
146
  const maxTokens = options.maxTokens ?? 3e3;
@@ -273,7 +292,30 @@ function createChatHandler(options) {
273
292
  (block) => block.type === "tool_use"
274
293
  );
275
294
  const results = [];
295
+ const ask = askUserDefinition ? toolUses.find((block) => block.name === ASK_USER_TOOL_NAME) : void 0;
296
+ const set = ask ? sanitizeQuestionSet(ask.input, `${exchangeId}-questions`) : null;
297
+ if (set) {
298
+ if (!streamedText) {
299
+ const lead = set.questions.length === 1 ? set.questions[0].prompt : copy.questionsIntro;
300
+ reasoning.close();
301
+ streamedText = true;
302
+ firstTextMs = Date.now() - startedAt;
303
+ answerText += lead;
304
+ send({ type: "delta", text: lead });
305
+ }
306
+ send({ type: "question", set });
307
+ break;
308
+ }
276
309
  for (const block of toolUses) {
310
+ if (block.name === ASK_USER_TOOL_NAME) {
311
+ results.push({
312
+ type: "tool_result",
313
+ tool_use_id: block.id,
314
+ is_error: true,
315
+ content: `Those questions could not be shown. Each needs a short prompt and 2 to 5 distinct options of at most ${SUGGESTION_MAX_CHARS} characters, and a set holds at most 4. Fix them, or ask in plain text.`
316
+ });
317
+ continue;
318
+ }
277
319
  const args = typeof block.input === "object" && block.input !== null ? block.input : {};
278
320
  send({
279
321
  type: "status",
@@ -477,6 +519,8 @@ function createFeedbackHandler(options = {}) {
477
519
  };
478
520
  }
479
521
  export {
522
+ ASK_USER_TOOL_NAME,
523
+ DEFAULT_ASK_USER_DESCRIPTION,
480
524
  DEFAULT_CHAT_COPY,
481
525
  DEFAULT_CHAT_LIMITS,
482
526
  createChatHandler,
package/chat/sim.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ReactNode } from 'react';
2
- import { ChatCard, ChatTransport } from './types';
2
+ import { ChatCard, ChatQuestionSet, ChatTransport } from './types';
3
3
  /** One status beat shown while the scripted reply "thinks". */
4
4
  export interface ScriptStep {
5
5
  /** The AgentStatus label. */
@@ -19,6 +19,17 @@ export interface ScriptedReply {
19
19
  content?: ReactNode;
20
20
  /** The chips offered under the reply. Each should be a scripted prompt so no path dead-ends. */
21
21
  followups?: string[];
22
+ /**
23
+ * Questions the reply asks, drawn as a QuestionCard in place of the chips.
24
+ * The `text` should ask them in words too, as a live model's reply would.
25
+ */
26
+ question?: ChatQuestionSet;
27
+ /**
28
+ * The reply to whatever the visitor answers to `question`: one reply for
29
+ * every answer, or a function of the answer as the message it was sent
30
+ * as. A scripted prompt that matches the answer exactly still wins.
31
+ */
32
+ answered?: ScriptedReply | ((answer: string) => ScriptedReply);
22
33
  }
23
34
  /** A prompt the visitor can send, and the reply it routes to. */
24
35
  export interface ScriptNode extends ScriptedReply {
package/chat/sim.js CHANGED
@@ -50,11 +50,23 @@ function createSimTransport(script, timing = {}) {
50
50
  const byPrompt = new Map(script.nodes.map((node) => [normalize(node.prompt), node]));
51
51
  const fallbacks = script.fallbacks.length > 0 ? script.fallbacks : [DEFAULT_FALLBACK_REPLY];
52
52
  const filesReply = script.filesReply ?? defaultFilesReply;
53
+ const asking = /* @__PURE__ */ new Map();
54
+ const collect = (reply) => {
55
+ if (!reply.question || asking.has(normalize(reply.text))) return;
56
+ asking.set(normalize(reply.text), reply);
57
+ if (reply.answered && typeof reply.answered !== "function") collect(reply.answered);
58
+ };
59
+ for (const reply of [...script.nodes, ...script.fallbacks]) collect(reply);
53
60
  const defaultFollowups = script.nodes.map((node) => node.prompt).filter(fitsChip).slice(0, STARTER_COUNT);
54
61
  let exchangeCount = 0;
55
62
  return async function* simTransport(messages, signal) {
56
63
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
57
- const node = lastUser ? byPrompt.get(normalize(lastUser.content)) : void 0;
64
+ const prompted = lastUser ? byPrompt.get(normalize(lastUser.content)) : void 0;
65
+ const previous = messages[messages.length - 2];
66
+ const asked = !prompted && previous?.role === "assistant" ? asking.get(normalize(previous.content)) : void 0;
67
+ const answered = asked?.answered && lastUser ? typeof asked.answered === "function" ? asked.answered(lastUser.content) : asked.answered : void 0;
68
+ if (answered) collect(answered);
69
+ const node = prompted ?? answered;
58
70
  const attached = !node ? lastUser?.attachments ?? [] : [];
59
71
  const reply = node ?? (attached.length > 0 ? filesReply(attached.map((file) => file.name)) : fallbacks[exchangeCount % fallbacks.length]);
60
72
  if (!node && attached.length === 0) exchangeCount += 1;
@@ -70,8 +82,12 @@ function createSimTransport(script, timing = {}) {
70
82
  }
71
83
  if (reply.content) yield { type: "content", node: reply.content };
72
84
  if (reply.card) yield { type: "card", card: reply.card };
73
- const followups = (reply.followups ?? defaultFollowups).filter(fitsChip);
74
- if (followups.length > 0) yield { type: "followups", items: followups };
85
+ if (reply.question) {
86
+ yield { type: "question", set: reply.question };
87
+ } else {
88
+ const followups = (reply.followups ?? defaultFollowups).filter(fitsChip);
89
+ if (followups.length > 0) yield { type: "followups", items: followups };
90
+ }
75
91
  yield { type: "done" };
76
92
  };
77
93
  }
package/chat/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { AttachmentItem } from '../components/Attachment/fileTypes';
3
+ import { QuestionCardAnswer, QuestionCardQuestion } from '../components/QuestionCard/QuestionCard';
3
4
  /**
4
5
  * Rich content that can cross a network: a kind the host knows how to draw
5
6
  * and the data to draw it from. The provider's `renderers` map turns one
@@ -48,6 +49,33 @@ export interface ChatArtifact {
48
49
  }
49
50
  /** The card kind an artifact travels as. Its `data` is a `ChatArtifact`. */
50
51
  export declare const ARTIFACT_CARD_KIND = "artifact";
52
+ /** One question the agent asks: a prompt and the answers on offer. The shape QuestionCard draws. */
53
+ export type ChatQuestion = QuestionCardQuestion;
54
+ /** What the visitor answered to one question. */
55
+ export type ChatQuestionAnswer = QuestionCardAnswer;
56
+ /**
57
+ * Questions the agent asks at the end of a reply, with the answers it can
58
+ * list: one question or a short set, each taking one option or several.
59
+ * The reply's text asks in words too, so a transport or a renderer that
60
+ * drops the set still leaves a conversation that reads correctly.
61
+ */
62
+ export interface ChatQuestionSet {
63
+ /** Stable identity within the conversation. */
64
+ id: string;
65
+ /** Asked one at a time when there is more than one. A set of more than one holds the composer until it is answered or skipped. */
66
+ questions: ChatQuestion[];
67
+ }
68
+ /**
69
+ * The answers to a question set as a user turn draws them: each question's
70
+ * prompt and the labels picked. The turn's `text` is the same answers as
71
+ * the plain message the model reads.
72
+ */
73
+ export interface ChatReply {
74
+ items: {
75
+ prompt: string;
76
+ answers: string[];
77
+ }[];
78
+ }
51
79
  export type ChatEvent =
52
80
  /** A live status step: the AgentStatus label, with an optional reasoning trace point. */
53
81
  {
@@ -106,6 +134,12 @@ export type ChatEvent =
106
134
  type: 'card';
107
135
  card: ChatCard;
108
136
  }
137
+ /** Questions the agent asks with answers to pick, drawn under the committed
138
+ turn in place of follow-ups: a turn that asks is never asked about. */
139
+ | {
140
+ type: 'question';
141
+ set: ChatQuestionSet;
142
+ }
109
143
  /** Terminal: the response is complete. */
110
144
  | {
111
145
  type: 'done';
@@ -121,7 +155,7 @@ export type ChatEvent =
121
155
  message: string;
122
156
  };
123
157
  /** Every event kind, for documentation that enumerates the protocol. */
124
- export declare const CHAT_EVENT_KINDS: readonly ["status", "model", "models", "delta", "exchange", "followups", "content", "card", "done", "notice", "error"];
158
+ export declare const CHAT_EVENT_KINDS: readonly ["status", "model", "models", "delta", "exchange", "followups", "content", "card", "question", "done", "notice", "error"];
125
159
  export interface ChatTransportMessage {
126
160
  role: 'user' | 'assistant';
127
161
  content: string;
@@ -187,6 +221,27 @@ export interface ChatTurn {
187
221
  * carried from a transport's `card` event. Drawn where `content` is.
188
222
  */
189
223
  card?: ChatCard;
224
+ /**
225
+ * Questions this assistant turn asks, drawn under it as a QuestionCard in
226
+ * place of follow-ups. Carried from a transport's `question` event.
227
+ */
228
+ question?: ChatQuestionSet;
229
+ /**
230
+ * The answers given to `question`. Undefined while it is open; an empty
231
+ * array records a skip. Kept on the asking turn, so the card renders its
232
+ * answered state where it was asked.
233
+ */
234
+ answers?: ChatQuestionAnswer[];
235
+ /**
236
+ * The answers a user turn carries, drawn as the bubble when there is more
237
+ * than one to show. Its `text` is the same answers as a plain message.
238
+ */
239
+ reply?: ChatReply;
240
+ /**
241
+ * The follow-up chip the visitor sent from under this turn. Once set the
242
+ * row is a record: the chip marked, the rest dimmed and inert.
243
+ */
244
+ followupChosen?: string;
190
245
  /**
191
246
  * Render `content` on its own in the thread, outside any message chrome,
192
247
  * for conversation furniture like a day marker.
@@ -216,6 +271,8 @@ export interface LiveResponse {
216
271
  content?: ReactNode;
217
272
  /** Carried from the transport's `card` event into the committed turn. */
218
273
  card?: ChatCard;
274
+ /** Carried from the transport's `question` event into the committed turn. */
275
+ question?: ChatQuestionSet;
219
276
  }
220
277
  /**
221
278
  * One model the widget can offer in its picker and the server handler can
package/chat/types.js CHANGED
@@ -8,6 +8,7 @@ const CHAT_EVENT_KINDS = [
8
8
  "followups",
9
9
  "content",
10
10
  "card",
11
+ "question",
11
12
  "done",
12
13
  "notice",
13
14
  "error"
package/chat/useChat.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { AttachmentItem } from '../components/Attachment/fileTypes';
3
- import { ChatTransport, ChatTurn, LiveResponse } from './types';
3
+ import { ChatQuestionAnswer, ChatReply, ChatTransport, ChatTurn, LiveResponse } from './types';
4
4
  /** The copy the hook shows when a transport ends a turn with nothing to show. */
5
5
  export interface ChatPlaceholderCopy {
6
6
  /** A completion that produced no text. */
@@ -35,7 +35,22 @@ export interface ChatState {
35
35
  send: (text: string, options?: {
36
36
  content?: ReactNode;
37
37
  attachments?: AttachmentItem[];
38
+ reply?: ChatReply;
38
39
  }) => boolean;
40
+ /**
41
+ * Answer the questions an assistant turn asked: records the answers on
42
+ * that turn, so its card renders the record, and sends them as the next
43
+ * message. Returns false, recording nothing, when the send is refused
44
+ * (a response in flight) or the turn asks nothing.
45
+ */
46
+ answerQuestion: (turnId: string, answers: ChatQuestionAnswer[]) => boolean;
47
+ /** Decline the questions an assistant turn asked. Records the skip and sends nothing. */
48
+ skipQuestion: (turnId: string) => void;
49
+ /**
50
+ * Send a follow-up chip from under a turn and record which it was, so the
51
+ * row keeps the choice. Returns whether the send was accepted.
52
+ */
53
+ chooseFollowup: (turnId: string, label: string) => boolean;
39
54
  /** Stop the in-flight response, keeping the text that already arrived. */
40
55
  stop: () => void;
41
56
  /** New chat: abort anything in flight and clear the conversation. */
package/chat/useChat.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { useState, useRef, useEffect, useCallback } from "react";
3
3
  import { revokeAttachmentUrls } from "../components/Attachment/useAttachments.js";
4
4
  import { createStreamReveal } from "../components/StreamingText/useStreamReveal.js";
5
+ import { describeAnswers, serializeReply, sanitizeQuestionSet } from "./questions.js";
5
6
  let nextId = 0;
6
7
  const makeId = () => `turn-${++nextId}`;
7
8
  const DEFAULT_PLACEHOLDER_COPY = {
@@ -50,6 +51,7 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
50
51
  followups: finished.followups,
51
52
  content: finished.content,
52
53
  card: finished.card,
54
+ question: finished.question,
53
55
  durationSeconds: finished.durationSeconds ?? Math.max(1, Math.round((Date.now() - startedAt) / 1e3))
54
56
  }
55
57
  ]);
@@ -83,7 +85,8 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
83
85
  role: "user",
84
86
  text: trimmed,
85
87
  content: options?.content,
86
- attachments
88
+ attachments,
89
+ reply: options?.reply
87
90
  }
88
91
  ]);
89
92
  let current = {
@@ -126,6 +129,11 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
126
129
  case "card":
127
130
  current = { ...current, card: event.card };
128
131
  break;
132
+ case "question": {
133
+ const set = sanitizeQuestionSet(event.set, `${current.id}-questions`);
134
+ if (set) current = { ...current, question: set };
135
+ break;
136
+ }
129
137
  case "status":
130
138
  update({
131
139
  ...current,
@@ -206,6 +214,32 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
206
214
  (prev) => prev.map((turn) => turn.id === turnId ? { ...turn, followups } : turn)
207
215
  );
208
216
  }, []);
217
+ const patchTurn = useCallback((turnId, patch) => {
218
+ setTurns((prev) => prev.map((turn) => turn.id === turnId ? { ...turn, ...patch } : turn));
219
+ }, []);
220
+ const answerQuestion = useCallback(
221
+ (turnId, answers) => {
222
+ const set = turnsRef.current.find((turn) => turn.id === turnId)?.question;
223
+ if (!set) return false;
224
+ const reply = describeAnswers(set, answers);
225
+ if (!send(serializeReply(reply), { reply })) return false;
226
+ patchTurn(turnId, { answers });
227
+ return true;
228
+ },
229
+ [send, patchTurn]
230
+ );
231
+ const skipQuestion = useCallback(
232
+ (turnId) => patchTurn(turnId, { answers: [] }),
233
+ [patchTurn]
234
+ );
235
+ const chooseFollowup = useCallback(
236
+ (turnId, label) => {
237
+ if (!send(label)) return false;
238
+ patchTurn(turnId, { followupChosen: label });
239
+ return true;
240
+ },
241
+ [send, patchTurn]
242
+ );
209
243
  const injectTurn = useCallback((turn) => {
210
244
  setTurns((prev) => [...prev, { ...turn, id: makeId() }]);
211
245
  }, []);
@@ -232,7 +266,10 @@ function useChat(transport, placeholders = DEFAULT_PLACEHOLDER_COPY) {
232
266
  reset,
233
267
  injectTurn,
234
268
  setTurnFeedback,
235
- setTurnFollowups
269
+ setTurnFollowups,
270
+ answerQuestion,
271
+ skipQuestion,
272
+ chooseFollowup
236
273
  };
237
274
  }
238
275
  export {
@@ -6,7 +6,7 @@
6
6
  .ds-alert-dialog {
7
7
  position: fixed;
8
8
  inset: 0;
9
- z-index: 100;
9
+ z-index: var(--layer-modal);
10
10
  display: flex;
11
11
  align-items: center;
12
12
  justify-content: center;
@@ -102,7 +102,7 @@
102
102
 
103
103
  .ds-anchor-nav__panel {
104
104
  position: absolute;
105
- z-index: 10;
105
+ z-index: var(--layer-dropdown);
106
106
  top: 50%;
107
107
  right: 0;
108
108
  min-width: 200px;
@@ -11,7 +11,7 @@
11
11
  position: fixed;
12
12
  top: 0;
13
13
  left: 0;
14
- z-index: 100;
14
+ z-index: var(--layer-nav-rail);
15
15
  display: flex;
16
16
  flex-direction: column;
17
17
  justify-content: space-between;
@@ -548,7 +548,7 @@
548
548
  top: 0;
549
549
  left: 64px;
550
550
  /* One step under the rail, so the slide starts from beneath it. */
551
- z-index: 99;
551
+ z-index: calc(var(--layer-nav-rail) - 1);
552
552
  height: 100vh;
553
553
  border-left: var(--border-025) solid var(--color-bg-container-border);
554
554
  border-right: var(--border-025) solid var(--color-bg-container-border);
@@ -762,7 +762,7 @@
762
762
  a 40px header row's. */
763
763
  top: var(--padding-400);
764
764
  left: var(--padding-400);
765
- z-index: 80;
765
+ z-index: var(--layer-nav);
766
766
  display: flex;
767
767
  align-items: center;
768
768
  justify-content: center;
@@ -781,7 +781,7 @@
781
781
  display: block;
782
782
  position: fixed;
783
783
  inset: 0;
784
- z-index: 90;
784
+ z-index: var(--layer-nav-scrim);
785
785
  background: var(--color-scrim);
786
786
  }
787
787
 
@@ -794,7 +794,7 @@
794
794
  top: 0;
795
795
  left: 0;
796
796
  bottom: 0;
797
- z-index: 100;
797
+ z-index: var(--layer-nav-rail);
798
798
  width: 280px;
799
799
  height: 100dvh;
800
800
  padding: var(--padding-500) 0;