@jini-ai/chat 0.6.5 → 0.6.7

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 (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +184 -97
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +35 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/policies.d.ts +1 -0
  23. package/dist/core/durable-runs/policies.js +1 -0
  24. package/dist/core/durable-runs/ports.d.ts +6 -2
  25. package/dist/core/index.d.ts +2 -0
  26. package/dist/core/index.js +2 -0
  27. package/dist/core/messages.d.ts +2 -0
  28. package/dist/core/partial-json.d.ts +6 -7
  29. package/dist/core/partial-json.js +81 -47
  30. package/dist/core/persistence/title.js +4 -2
  31. package/dist/core/question-answer.d.ts +23 -0
  32. package/dist/core/question-answer.js +66 -0
  33. package/dist/core/question-form/format.d.ts +6 -6
  34. package/dist/core/question-form/format.js +16 -17
  35. package/dist/core/question-form/normalize.d.ts +18 -0
  36. package/dist/core/question-form/normalize.js +112 -122
  37. package/dist/core/question-form/scan.js +141 -203
  38. package/dist/core/run-activity.js +62 -49
  39. package/dist/core/run-events/entry.d.ts +10 -1
  40. package/dist/core/run-events/entry.js +117 -87
  41. package/dist/core/todos.d.ts +1 -1
  42. package/dist/core/todos.js +60 -85
  43. package/dist/core/tool-events.js +14 -12
  44. package/dist/core/transcript.js +62 -16
  45. package/dist/core/turn-usage.d.ts +2 -1
  46. package/dist/core/turn-usage.js +56 -27
  47. package/dist/core/user-text-redaction.js +3 -0
  48. package/dist/core/util/manifest.js +77 -82
  49. package/dist/core/util/markdown-context.js +51 -53
  50. package/dist/core/util/parser.js +136 -144
  51. package/dist/core/util/pointer.js +16 -16
  52. package/dist/core/util/strip.js +57 -67
  53. package/dist/core/util/validate.js +15 -17
  54. package/dist/embed-widget/browser/session-store.js +21 -12
  55. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  56. package/dist/embed-widget/vanilla/index.js +81 -70
  57. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  58. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  59. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  60. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  61. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  62. package/dist/react/components/ChatSettingsDialog.js +72 -0
  63. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  64. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  65. package/dist/react/components/Composer.d.ts +5 -1
  66. package/dist/react/components/Composer.js +126 -82
  67. package/dist/react/components/ConversationList.js +25 -39
  68. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  69. package/dist/react/components/ConversationTitleEditor.js +54 -0
  70. package/dist/react/components/Icon.d.ts +1 -1
  71. package/dist/react/components/Icon.js +25 -45
  72. package/dist/react/components/Markdown.js +134 -123
  73. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  74. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  75. package/dist/react/components/MessageList.d.ts +13 -13
  76. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  77. package/dist/react/components/MessageList.hooks.js +19 -3
  78. package/dist/react/components/MessageList.js +34 -10
  79. package/dist/react/components/MessageRow.js +48 -32
  80. package/dist/react/components/MessageRow.usage.d.ts +5 -2
  81. package/dist/react/components/MessageRow.usage.js +20 -11
  82. package/dist/react/components/MidRunUserMessage.js +16 -3
  83. package/dist/react/components/QuestionForm.js +148 -76
  84. package/dist/react/components/QuestionsPanel.js +1 -1
  85. package/dist/react/components/TodoCard.js +12 -6
  86. package/dist/react/components/ToolCard.js +47 -40
  87. package/dist/react/components/composer-discovery.js +6 -1
  88. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  89. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  90. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  91. package/dist/react/ext-event-renderer-registry.js +5 -2
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  93. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  94. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  95. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  96. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  98. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  99. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  100. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  102. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  104. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  106. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  108. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  109. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  110. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  112. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  114. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  115. package/dist/react/features/chat-pane/model-options.d.ts +18 -1
  116. package/dist/react/features/chat-pane/model-options.js +49 -20
  117. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  118. package/dist/react/features/chat-pane/rules.js +84 -46
  119. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  120. package/dist/react/features/chat-pane/styles.js +156 -37
  121. package/dist/react/features/chat-pane/types.d.ts +17 -8
  122. package/dist/react/hooks/assistant-chats.js +12 -5
  123. package/dist/react/hooks/composer-draft-cache.js +116 -118
  124. package/dist/react/hooks/composer-history-storage.js +5 -5
  125. package/dist/react/hooks/fab-position.js +7 -4
  126. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  127. package/dist/react/hooks/useAnsweredCards.js +38 -11
  128. package/dist/react/hooks/useArtifactStream.js +28 -22
  129. package/dist/react/hooks/useComposer.d.ts +20 -0
  130. package/dist/react/hooks/useComposer.js +22 -9
  131. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  132. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  133. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  134. package/dist/react/hooks/useConversation.d.ts +7 -0
  135. package/dist/react/hooks/useConversation.js +23 -10
  136. package/dist/react/hooks/useExtEventGroups.js +39 -39
  137. package/dist/react/hooks/useQuestionForms.js +21 -26
  138. package/dist/react/hooks/useRunStream.js +10 -2
  139. package/dist/react/index.d.ts +7 -1
  140. package/dist/react/index.js +9 -0
  141. package/dist/react/message-blocks.d.ts +6 -4
  142. package/dist/react/message-blocks.js +94 -72
  143. package/dist/react/slots.d.ts +2 -2
  144. package/dist/react/tool-call-display.js +19 -17
  145. package/dist/server/durable-runs/recover.d.ts +1 -1
  146. package/dist/server/durable-runs/recover.js +139 -76
  147. package/dist/server/run-finalizer/entry.js +25 -15
  148. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  149. package/dist/store/legacy/sqlite/conversations.js +69 -98
  150. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  151. package/dist/store/legacy/sqlite/messages.js +113 -71
  152. package/dist/store/runtime/chat-history-store.js +9 -3
  153. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  154. package/dist/store/runtime/durable-run-store.js +165 -117
  155. package/dist/store/runtime/history-tools.js +118 -82
  156. package/dist/store/runtime/run-ledger.d.ts +4 -1
  157. package/dist/store/runtime/run-ledger.js +56 -16
  158. package/dist/store/sql/cursor.js +38 -18
  159. package/dist/store/sql/store.d.ts +18 -0
  160. package/dist/store/sql/store.js +46 -15
  161. package/package.json +12 -10
@@ -19,7 +19,6 @@ export function createBrowserSessionStore(args) {
19
19
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 28)
20
20
  throw new Error('Invalid byte limit');
21
21
  const empty = () => ({ open: false, messages: [] });
22
- const bytes = (value) => new TextEncoder().encode(value).length;
23
22
  const clear = (_args) => {
24
23
  try {
25
24
  storage.removeItem(transcriptKey);
@@ -38,16 +37,7 @@ export function createBrowserSessionStore(args) {
38
37
  if (raw === null)
39
38
  return empty();
40
39
  try {
41
- if (bytes(raw) > maxBytes)
42
- throw new Error('Transcript exceeds byte limit');
43
- const value = JSON.parse(raw);
44
- if (typeof value !== 'object' || value === null || Array.isArray(value))
45
- throw new Error('Invalid envelope');
46
- const state = value;
47
- if (typeof state.open !== 'boolean' || !Array.isArray(state.messages)
48
- || state.messages.length > maxMessages || !state.messages.every(value => validateMessage({ value })))
49
- throw new Error('Invalid transcript');
50
- return { open: state.open, messages: state.messages };
40
+ return parseTranscript({ raw, maxBytes, maxMessages, validateMessage });
51
41
  }
52
42
  catch {
53
43
  clear({});
@@ -58,7 +48,7 @@ export function createBrowserSessionStore(args) {
58
48
  try {
59
49
  let messages = maxMessages === 0 ? [] : state.messages.slice(-maxMessages);
60
50
  let serialized = JSON.stringify({ open: state.open, messages });
61
- while (messages.length > 0 && bytes(serialized) > maxBytes) {
51
+ while (messages.length > 0 && utf8Bytes(serialized) > maxBytes) {
62
52
  messages = messages.slice(1);
63
53
  serialized = JSON.stringify({ open: state.open, messages });
64
54
  }
@@ -100,4 +90,23 @@ export function createBrowserSessionStore(args) {
100
90
  },
101
91
  };
102
92
  }
93
+ const utf8Bytes = (value) => new TextEncoder().encode(value).length;
94
+ /**
95
+ * Parses and validates one stored transcript envelope. Throws on any defect so the caller can
96
+ * clear the whole entry (no partial recovery). Oversized envelopes are rejected before parsing.
97
+ */
98
+ function parseTranscript(args) {
99
+ const { raw, maxBytes, maxMessages, validateMessage } = args;
100
+ if (utf8Bytes(raw) > maxBytes)
101
+ throw new Error('Transcript exceeds byte limit');
102
+ const value = JSON.parse(raw);
103
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
104
+ throw new Error('Invalid envelope');
105
+ const state = value;
106
+ if (typeof state.open !== 'boolean' || !Array.isArray(state.messages))
107
+ throw new Error('Invalid transcript');
108
+ if (state.messages.length > maxMessages || !state.messages.every(value => validateMessage({ value })))
109
+ throw new Error('Invalid transcript');
110
+ return { open: state.open, messages: state.messages };
111
+ }
103
112
  //# sourceMappingURL=session-store.js.map
@@ -8,19 +8,17 @@ export function mapJsonChatSseFrame({ frame, directiveEventName, streamError })
8
8
  return {};
9
9
  const data = JSON.parse(frame.data);
10
10
  const obj = typeof data === 'object' && data !== null ? data : {};
11
- if (frame.event === 'text') {
12
- if (typeof obj.delta !== 'string')
13
- throw new Error('Invalid text frame');
14
- return { event: { kind: 'text', text: obj.delta } };
11
+ switch (frame.event) {
12
+ case 'text':
13
+ if (typeof obj.delta !== 'string')
14
+ throw new Error('Invalid text frame');
15
+ return { event: { kind: 'text', text: obj.delta } };
16
+ case 'error': return { error: new Error(nonEmptyString(obj.message) ?? streamError) };
17
+ case 'end': return { done: true };
18
+ default: return frame.event === directiveEventName ? { event: { kind: 'ext', name: directiveEventName, data } } : {};
15
19
  }
16
- if (frame.event === 'error')
17
- return { error: new Error(typeof obj.message === 'string' && obj.message ? obj.message : streamError) };
18
- if (frame.event === 'end')
19
- return { done: true };
20
- if (frame.event === directiveEventName)
21
- return { event: { kind: 'ext', name: directiveEventName, data } };
22
- return {};
23
20
  }
21
+ const nonEmptyString = (value) => (typeof value === 'string' && value ? value : undefined);
24
22
  async function* responseChunks(body, signal) {
25
23
  const reader = body.getReader();
26
24
  const cancel = () => { void reader.cancel().catch(() => { }); };
@@ -71,15 +69,11 @@ export function createFetchSseTransport(args, options = {}) {
71
69
  };
72
70
  return {
73
71
  async startRun(input, handlers) {
74
- let latestIndex = input.history.length - 1;
75
- while (latestIndex >= 0 && input.history[latestIndex]?.role !== 'user')
76
- latestIndex -= 1;
72
+ const latestIndex = latestUserIndex(input.history);
77
73
  const message = input.history[latestIndex]?.content;
78
74
  if (!message)
79
75
  throw new Error(args.copy.noUserMessage);
80
- const history = (args.maxHistoryMessages === 0 ? [] : input.history.slice(Math.max(0, latestIndex - args.maxHistoryMessages), latestIndex))
81
- .filter(m => m.content.trim().length > 0)
82
- .map(m => ({ role: m.role, content: m.content.slice(0, args.maxHistoryMessageChars) }));
76
+ const history = priorHistory({ history: input.history, latestIndex, maxMessages: args.maxHistoryMessages, maxChars: args.maxHistoryMessageChars });
83
77
  // Build before registering a run, so a synchronous host-builder failure leaks no state.
84
78
  const request = args.requestBuilder({ endpoint: args.endpoint, agentId: args.agentId, message, history, input });
85
79
  const runId = args.ids({});
@@ -87,84 +81,8 @@ export function createFetchSseTransport(args, options = {}) {
87
81
  throw new Error('Run ID must be unique and nonempty');
88
82
  const controller = options.createAbortController?.({}) ?? new AbortController();
89
83
  activeRuns.set(runId, controller);
90
- const collected = [];
91
- let settled = false;
92
- const signals = [input.signal, ...(input.cancelSignal ? [input.cancelSignal] : [])];
93
- const finish = () => {
94
- if (settled)
95
- return;
96
- settled = true;
97
- activeRuns.delete(runId);
98
- for (const signal of signals)
99
- signal.removeEventListener('abort', abort);
100
- controller.signal.removeEventListener('abort', onAborted);
101
- observe(runId, 'settled');
102
- handlers.onDone([...collected]);
103
- };
104
- const onAborted = () => { observe(runId, 'aborted'); finish(); };
105
- const abort = () => controller.abort();
106
- controller.signal.addEventListener('abort', onAborted, { once: true });
107
- for (const signal of signals)
108
- signal.addEventListener('abort', abort, { once: true });
109
- observe(runId, 'started');
110
- if (signals.some(signal => signal.aborted))
111
- abort();
112
- void (async () => {
113
- try {
114
- if (settled)
115
- return;
116
- const response = await args.fetch(args.endpoint, { ...request, signal: controller.signal });
117
- if (settled) {
118
- try {
119
- await response.body?.cancel();
120
- }
121
- catch { /* closed body */ }
122
- return;
123
- }
124
- if (!response.ok) {
125
- const body = await response.json().catch(() => null);
126
- if (settled)
127
- return;
128
- const error = typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string' && body.error
129
- ? body.error : args.copy.requestFailed({ status: response.status });
130
- observe(runId, 'error');
131
- handlers.onError(new Error(error));
132
- return;
133
- }
134
- if (!response.body)
135
- throw new Error(args.copy.missingBody);
136
- for await (const frame of args.decode({ source: responseChunks(response.body, controller.signal) })) {
137
- if (settled)
138
- break;
139
- const result = args.frameMapper({ frame });
140
- if (result.event) {
141
- collected.push(result.event);
142
- handlers.onEvent(result.event);
143
- }
144
- if (settled)
145
- break;
146
- if (result.error) {
147
- observe(runId, 'error');
148
- handlers.onError(result.error);
149
- }
150
- if (settled)
151
- break;
152
- if (result.done) {
153
- finish();
154
- break;
155
- }
156
- }
157
- }
158
- catch (error) {
159
- if (!settled && !controller.signal.aborted) {
160
- observe(runId, 'error');
161
- handlers.onError(error instanceof Error ? error : new Error(String(error)));
162
- }
163
- }
164
- finally {
165
- finish();
166
- }
167
- })();
84
+ const run = openRun({ controller, input, handlers }, { observe: phase => observe(runId, phase), release: () => activeRuns.delete(runId) });
85
+ void pumpRun({ run, args, request });
168
86
  return { runId };
169
87
  },
170
88
  async reattachRun(_runId, handlers) { handlers.onDone([]); },
@@ -172,4 +90,99 @@ export function createFetchSseTransport(args, options = {}) {
172
90
  async stopRun(runId) { activeRuns.get(runId)?.abort(); },
173
91
  };
174
92
  }
93
+ function latestUserIndex(history) {
94
+ let index = history.length - 1;
95
+ while (index >= 0 && history[index]?.role !== 'user')
96
+ index -= 1;
97
+ return index;
98
+ }
99
+ /** Prior turns only (the latest user turn travels as `message`), non-empty, count- and char-bounded. */
100
+ function priorHistory({ history, latestIndex, maxMessages, maxChars }) {
101
+ return (maxMessages === 0 ? [] : history.slice(Math.max(0, latestIndex - maxMessages), latestIndex))
102
+ .filter(m => m.content.trim().length > 0)
103
+ .map(m => ({ role: m.role, content: m.content.slice(0, maxChars) }));
104
+ }
105
+ /** Wires abort listeners and returns the settle-once handle; an already-aborted signal aborts at once. */
106
+ function openRun({ controller, input, handlers }, { observe, release }) {
107
+ const collected = [];
108
+ let settled = false;
109
+ const signals = [input.signal, ...(input.cancelSignal ? [input.cancelSignal] : [])];
110
+ const finish = () => {
111
+ if (settled)
112
+ return;
113
+ settled = true;
114
+ release();
115
+ for (const signal of signals)
116
+ signal.removeEventListener('abort', abort);
117
+ controller.signal.removeEventListener('abort', onAborted);
118
+ observe('settled');
119
+ handlers.onDone([...collected]);
120
+ };
121
+ const onAborted = () => { observe('aborted'); finish(); };
122
+ const abort = () => controller.abort();
123
+ controller.signal.addEventListener('abort', onAborted, { once: true });
124
+ for (const signal of signals)
125
+ signal.addEventListener('abort', abort, { once: true });
126
+ observe('started');
127
+ if (signals.some(signal => signal.aborted))
128
+ abort();
129
+ const report = (error) => { observe('error'); handlers.onError(error); };
130
+ return { controller, handlers, collected, isSettled: () => settled, finish, report };
131
+ }
132
+ /** Performs the POST and streams frames; always settles the run exactly once when it returns. */
133
+ async function pumpRun({ run, args, request }) {
134
+ try {
135
+ if (run.isSettled())
136
+ return;
137
+ const response = await args.fetch(args.endpoint, { ...request, signal: run.controller.signal });
138
+ await deliverResponse({ run, args, response });
139
+ }
140
+ catch (error) {
141
+ if (!run.isSettled() && !run.controller.signal.aborted)
142
+ run.report(error instanceof Error ? error : new Error(String(error)));
143
+ }
144
+ finally {
145
+ run.finish();
146
+ }
147
+ }
148
+ async function deliverResponse({ run, args, response }) {
149
+ if (run.isSettled()) {
150
+ try {
151
+ await response.body?.cancel();
152
+ }
153
+ catch { /* closed body */ }
154
+ return;
155
+ }
156
+ if (!response.ok)
157
+ return reportHttpFailure({ run, response, requestFailed: args.copy.requestFailed });
158
+ if (!response.body)
159
+ throw new Error(args.copy.missingBody);
160
+ for await (const frame of args.decode({ source: responseChunks(response.body, run.controller.signal) })) {
161
+ if (run.isSettled() || !dispatchFrame({ run, result: args.frameMapper({ frame }) }))
162
+ break;
163
+ }
164
+ }
165
+ async function reportHttpFailure({ run, response, requestFailed }) {
166
+ const body = await response.json().catch(() => null);
167
+ if (run.isSettled())
168
+ return;
169
+ const serverError = typeof body === 'object' && body !== null && 'error' in body ? nonEmptyString(body.error) : undefined;
170
+ run.report(new Error(serverError ?? requestFailed({ status: response.status })));
171
+ }
172
+ /** Delivers one mapped frame; returns false once the run is settled (by a handler or an end frame). */
173
+ function dispatchFrame({ run, result }) {
174
+ if (result.event) {
175
+ run.collected.push(result.event);
176
+ run.handlers.onEvent(result.event);
177
+ }
178
+ if (run.isSettled())
179
+ return false;
180
+ if (result.error)
181
+ run.report(result.error);
182
+ if (run.isSettled() || result.done) {
183
+ run.finish();
184
+ return false;
185
+ }
186
+ return true;
187
+ }
175
188
  //# sourceMappingURL=fetch-sse.js.map
@@ -95,7 +95,8 @@ export function mountVanillaChat(args, options = {}) {
95
95
  const suggestions = node('div', 'jini-chat-vanilla__suggestions');
96
96
  if (copy.suggestionsLabel)
97
97
  suggestions.setAttribute('aria-label', copy.suggestionsLabel);
98
- const suggestionButtons = (args.suggestions ?? []).map(text => button(text));
98
+ const suggestionEntries = (args.suggestions ?? []).map(text => ({ text, button: button(text) }));
99
+ const suggestionButtons = suggestionEntries.map(entry => entry.button);
99
100
  suggestions.append(...suggestionButtons);
100
101
  log.append(greeting, suggestions);
101
102
  panel.append(header, confirmation, log, status, announcements, error, proposal, form);
@@ -123,6 +124,39 @@ export function mountVanillaChat(args, options = {}) {
123
124
  proposal.hidden = state.proposal === null;
124
125
  proposalText.textContent = state.proposal ? copy.proposalLabel({ title: state.proposal.navigate.target.title }) : '';
125
126
  };
127
+ const createRow = ({ message }) => {
128
+ const row = { element: node('div', `jini-chat-vanilla__message jini-chat-vanilla__message--${message.role}`), content: '\0' };
129
+ rows.set(message.id, row);
130
+ log.append(row.element);
131
+ return row;
132
+ };
133
+ const renderRow = ({ message }) => {
134
+ const row = rows.get(message.id) ?? createRow({ message });
135
+ if (row.content === message.content)
136
+ return;
137
+ if (message.role === 'user')
138
+ row.element.textContent = message.content;
139
+ else
140
+ args.renderMarkdown({ content: message.content, element: row.element });
141
+ row.content = message.content;
142
+ };
143
+ const announce = ({ text }) => {
144
+ if (!text)
145
+ return;
146
+ announcements.append(node('span', '', text));
147
+ // Bound the additions-only live region without replaying earlier replies.
148
+ while (announcements.childElementCount > 2)
149
+ announcements.firstElementChild.remove();
150
+ };
151
+ const announceSettledReplies = ({ messages }) => {
152
+ // A dedicated additions-only region announces each LIVE settled reply once. Markdown rows
153
+ // may be rebuilt while streaming, but restored transcripts and old text stay silent.
154
+ for (const message of messages) {
155
+ if (message.role !== 'assistant' || message.runStatus === 'running' || !liveReplies.delete(message.id))
156
+ continue;
157
+ announce({ text: rows.get(message.id)?.element.textContent });
158
+ }
159
+ };
126
160
  const renderRows = ({ state }) => {
127
161
  const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
128
162
  const ids = new Set(state.messages.map(message => message.id));
@@ -131,53 +165,33 @@ export function mountVanillaChat(args, options = {}) {
131
165
  row.element.remove();
132
166
  rows.delete(id);
133
167
  }
134
- for (const message of state.messages) {
135
- let row = rows.get(message.id);
136
- if (!row) {
137
- row = { element: node('div', `jini-chat-vanilla__message jini-chat-vanilla__message--${message.role}`), content: '\0' };
138
- rows.set(message.id, row);
139
- log.append(row.element);
140
- }
141
- if (row.content !== message.content) {
142
- if (message.role === 'user')
143
- row.element.textContent = message.content;
144
- else
145
- args.renderMarkdown({ content: message.content, element: row.element });
146
- row.content = message.content;
147
- }
148
- }
149
- // A dedicated additions-only region announces each LIVE settled reply once. Markdown rows
150
- // may be rebuilt while streaming, but restored transcripts and old text stay silent.
151
- for (const message of state.messages) {
152
- if (message.role !== 'assistant' || message.runStatus === 'running' || !liveReplies.delete(message.id))
153
- continue;
154
- const text = rows.get(message.id)?.element.textContent;
155
- if (text) {
156
- announcements.append(node('span', '', text));
157
- // Bound the additions-only live region without replaying earlier replies.
158
- while (announcements.childElementCount > 2)
159
- announcements.firstElementChild.remove();
160
- }
161
- }
168
+ for (const message of state.messages)
169
+ renderRow({ message });
170
+ announceSettledReplies({ messages: state.messages });
162
171
  if (state.messages.length === 0)
163
172
  announcements.replaceChildren();
164
173
  if (pinned)
165
174
  log.scrollTop = log.scrollHeight;
166
175
  };
167
- const renderFocus = ({ state, composerHadFocus, stopHadFocus, panelHadFocus }) => {
168
- if (state.open && !wasOpen && initialized) {
169
- if (state.busy)
170
- close.focus();
171
- else
172
- input.focus();
173
- }
174
- else if (state.open && state.busy && composerHadFocus)
176
+ const focusEntry = ({ busy }) => { if (busy)
177
+ close.focus();
178
+ else
179
+ input.focus(); };
180
+ const moveFocus = ({ state, composerHadFocus, stopHadFocus }) => {
181
+ if (!state.open)
182
+ return;
183
+ if (!wasOpen && initialized)
184
+ focusEntry({ busy: state.busy });
185
+ else if (state.busy && composerHadFocus)
175
186
  stop.focus();
176
- else if (state.open && !state.busy && stopHadFocus)
187
+ else if (!state.busy && stopHadFocus)
177
188
  input.focus();
189
+ };
190
+ const renderFocus = ({ state, composerHadFocus, stopHadFocus, panelHadFocus }) => {
191
+ moveFocus({ state, composerHadFocus, stopHadFocus });
192
+ // renderChrome (which always runs first) already ended modal semantics for a closed panel.
178
193
  if (!state.open && wasOpen) {
179
194
  confirmation.hidden = true;
180
- panel.setAttribute('aria-modal', 'false');
181
195
  if (panelHadFocus)
182
196
  fab.focus();
183
197
  }
@@ -216,8 +230,8 @@ export function mountVanillaChat(args, options = {}) {
216
230
  target.addEventListener(type, handler);
217
231
  listeners.push(() => target.removeEventListener(type, handler));
218
232
  };
219
- suggestionButtons.forEach(button => listen(button, 'click', () => {
220
- input.value = button.textContent ?? '';
233
+ suggestionEntries.forEach(({ text, button }) => listen(button, 'click', () => {
234
+ input.value = text;
221
235
  renderChrome({ state: controller.getState({}) });
222
236
  input.focus();
223
237
  }));
@@ -234,7 +248,8 @@ export function mountVanillaChat(args, options = {}) {
234
248
  confirmation.hidden = false;
235
249
  keep.focus();
236
250
  });
237
- listen(keep, 'click', () => { confirmation.hidden = true; reset.focus(); });
251
+ const dismissConfirmation = () => { confirmation.hidden = true; reset.focus(); };
252
+ listen(keep, 'click', dismissConfirmation);
238
253
  listen(confirm, 'click', () => { confirmation.hidden = true; input.value = ''; controller.reset({}); input.focus(); });
239
254
  listen(go, 'click', () => controller.acceptProposal({}));
240
255
  listen(stop, 'click', () => controller.cancel({}));
@@ -257,41 +272,37 @@ export function mountVanillaChat(args, options = {}) {
257
272
  form.requestSubmit();
258
273
  }
259
274
  });
260
- listen(document, 'keydown', event => {
261
- if (!controller.getState({}).open || !panel.contains(document.activeElement))
262
- return;
263
- const key = event;
264
- if (key.key === 'Escape') {
265
- key.preventDefault();
266
- if (!confirmation.hidden) {
267
- confirmation.hidden = true;
268
- reset.focus();
269
- }
270
- else
271
- controller.setOpen({ open: false });
272
- return;
273
- }
274
- if (key.key !== 'Tab')
275
- return;
275
+ const onEscape = ({ key }) => {
276
+ key.preventDefault();
277
+ if (!confirmation.hidden)
278
+ dismissConfirmation();
279
+ else
280
+ controller.setOpen({ open: false });
281
+ };
282
+ // Only reached while focus is inside the panel (see the document keydown guard), so wrapping
283
+ // happens exactly at the first/last visible enabled control.
284
+ const trapTab = ({ key }) => {
276
285
  const focusable = Array.from(panel.querySelectorAll('button:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]'))
277
286
  .filter(candidate => !candidate.closest('[hidden]'));
278
287
  const first = focusable[0];
279
288
  const last = focusable.at(-1);
280
- if (key.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
281
- key.preventDefault();
282
- last?.focus();
283
- }
284
- else if (!key.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
285
- key.preventDefault();
286
- first?.focus();
287
- }
289
+ if (document.activeElement !== (key.shiftKey ? first : last))
290
+ return;
291
+ key.preventDefault();
292
+ (key.shiftKey ? last : first)?.focus();
293
+ };
294
+ listen(document, 'keydown', event => {
295
+ if (!controller.getState({}).open || !panel.contains(document.activeElement))
296
+ return;
297
+ const key = event;
298
+ if (key.key === 'Escape')
299
+ onEscape({ key });
300
+ else if (key.key === 'Tab')
301
+ trapTab({ key });
288
302
  });
289
303
  listen(element, 'jini-chat-open', () => {
290
304
  controller.setOpen({ open: true });
291
- if (controller.getState({}).busy)
292
- close.focus();
293
- else
294
- input.focus();
305
+ focusEntry({ busy: controller.getState({}).busy });
295
306
  });
296
307
  const queued = { value: args.session.drain({}) };
297
308
  let cancelFrame = () => { };
@@ -44,14 +44,65 @@ function claimedCapabilities(options) {
44
44
  if (options.pageDriver !== undefined) {
45
45
  claims.push(...PAGE_CAPABILITIES.map((capability) => capability.id));
46
46
  }
47
- for (const [prefix, _executor] of Object.entries(options.executors ?? {})) {
48
- claims.push(prefix);
49
- }
47
+ claims.push(...Object.keys(options.executors ?? {}));
50
48
  return claims;
51
49
  }
52
50
  function errorMessage(error) {
53
51
  return error instanceof Error ? error.message : String(error);
54
52
  }
53
+ /** Parses a frame's data into an object, or says why it is not one. */
54
+ function parseFrameObject(data) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(data);
58
+ }
59
+ catch (error) {
60
+ return { error };
61
+ }
62
+ // `JSON.parse` succeeds on any valid JSON value, not just objects — a bare `null`, number,
63
+ // string or boolean parses cleanly and would throw *outside* this try/catch on the very next
64
+ // `frame['type']` read (`null` is the one of those that actually throws; the others just read
65
+ // back `undefined` harmlessly). Guarding here routes every one of those through the same
66
+ // `onError` path as an unparseable frame, rather than only some of them crashing the listener.
67
+ if (parsed === null || typeof parsed !== 'object') {
68
+ return { error: new Error(`malformed frame: expected a JSON object, got ${JSON.stringify(parsed)}`) };
69
+ }
70
+ return { frame: parsed };
71
+ }
72
+ /** Reads an `invocation` frame into the pane's action shape, refusing one with no usable id. */
73
+ function readInvocation(frame) {
74
+ const rawInvocationId = frame['invocationId'];
75
+ if (typeof rawInvocationId !== 'string' || rawInvocationId.length === 0) {
76
+ return { kind: 'malformed', error: new Error('invocation frame missing invocationId') };
77
+ }
78
+ return {
79
+ kind: 'invocation',
80
+ action: {
81
+ invocationId: rawInvocationId,
82
+ capabilityId: String(frame['capabilityId']),
83
+ input: (frame['input'] ?? {}),
84
+ },
85
+ };
86
+ }
87
+ /** Classifies one frame's raw data; the listener only applies the result. */
88
+ function readFrame(data) {
89
+ const parsed = parseFrameObject(data);
90
+ if ('error' in parsed)
91
+ return { kind: 'malformed', error: parsed.error };
92
+ const { frame } = parsed;
93
+ switch (frame['type']) {
94
+ case 'attached':
95
+ return { kind: 'attached', sessionId: String(frame['sessionId']), bindToken: String(frame['bindToken']) };
96
+ case 'error':
97
+ return { kind: 'refused', message: String(frame['message']) };
98
+ case 'invocation':
99
+ return readInvocation(frame);
100
+ default:
101
+ // Unknown frame types are ignored rather than treated as errors, so the daemon's event
102
+ // vocabulary can grow without every deployed surface needing to ship first.
103
+ return { kind: 'ignored' };
104
+ }
105
+ }
55
106
  /**
56
107
  * Opens the surface's connection and starts routing invocations.
57
108
  *
@@ -60,11 +111,8 @@ function errorMessage(error) {
60
111
  */
61
112
  export function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch }, options = {}) {
62
113
  const capabilities = claimedCapabilities(options);
63
- const send = request ?? (({ url, init, timeoutMs }) => {
64
- const timeout = AbortSignal.timeout(timeoutMs);
65
- const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
66
- return (fetch ?? globalThis.fetch)(url, { ...init, signal });
67
- });
114
+ // The only caller (`respond`) never passes its own signal, so the deadline is the whole signal.
115
+ const send = request ?? (({ url, init, timeoutMs }) => (fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
68
116
  let sessionId;
69
117
  /** Replaced on every reattach; a promise resolved once could never carry this. */
70
118
  let currentBindToken;
@@ -125,50 +173,8 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
125
173
  executed.delete(oldest);
126
174
  }
127
175
  }
128
- source.addEventListener('message', (event) => {
129
- let parsed;
130
- try {
131
- parsed = JSON.parse(event.data);
132
- }
133
- catch (error) {
134
- options.onError?.(error);
135
- return;
136
- }
137
- // `JSON.parse` succeeds on any valid JSON value, not just objects — a bare `null`, number,
138
- // string or boolean parses cleanly and would throw *outside* this try/catch on the very next
139
- // `frame['type']` read (`null` is the one of those that actually throws; the others just read
140
- // back `undefined` harmlessly). Guarding here routes every one of those through the same
141
- // `onError` path as an unparseable frame, rather than only some of them crashing the listener.
142
- if (parsed === null || typeof parsed !== 'object') {
143
- options.onError?.(new Error(`malformed frame: expected a JSON object, got ${JSON.stringify(parsed)}`));
144
- return;
145
- }
146
- const frame = parsed;
147
- if (frame['type'] === 'attached') {
148
- sessionId = String(frame['sessionId']);
149
- currentBindToken = String(frame['bindToken']);
150
- // A no-op after the first attach — which is exactly why `currentBindToken` exists.
151
- resolveReady({ sessionId, bindToken: currentBindToken });
152
- return;
153
- }
154
- if (frame['type'] === 'error') {
155
- rejectReady(new Error(String(frame['message'])));
156
- return;
157
- }
158
- // Unknown frame types are ignored rather than treated as errors, so the daemon's event
159
- // vocabulary can grow without every deployed surface needing to ship first.
160
- if (frame['type'] !== 'invocation')
161
- return;
162
- const rawInvocationId = frame['invocationId'];
163
- if (typeof rawInvocationId !== 'string' || rawInvocationId.length === 0) {
164
- options.onError?.(new Error('invocation frame missing invocationId'));
165
- return;
166
- }
167
- const action = {
168
- invocationId: rawInvocationId,
169
- capabilityId: String(frame['capabilityId']),
170
- input: (frame['input'] ?? {}),
171
- };
176
+ /** Runs one well-formed invocation: replay check, announcement, then pane or local serving. */
177
+ function handleInvocation(action) {
172
178
  // A redelivery after a dropped answer must not re-run the side effect.
173
179
  if (executed.has(action.invocationId)) {
174
180
  void respondError(action.invocationId, 'already executed on this surface').catch(() => undefined);
@@ -182,6 +188,28 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
182
188
  return;
183
189
  }
184
190
  void serveLocally(action);
191
+ }
192
+ source.addEventListener('message', (event) => {
193
+ const frame = readFrame(event.data);
194
+ switch (frame.kind) {
195
+ case 'attached':
196
+ sessionId = frame.sessionId;
197
+ currentBindToken = frame.bindToken;
198
+ // A no-op after the first attach — which is exactly why `currentBindToken` exists.
199
+ resolveReady({ sessionId, bindToken: currentBindToken });
200
+ return;
201
+ case 'refused':
202
+ rejectReady(new Error(frame.message));
203
+ return;
204
+ case 'malformed':
205
+ options.onError?.(frame.error);
206
+ return;
207
+ case 'invocation':
208
+ handleInvocation(frame.action);
209
+ return;
210
+ case 'ignored':
211
+ return;
212
+ }
185
213
  });
186
214
  source.addEventListener('error', (event) => {
187
215
  // The daemon deletes a session's bind token the instant that connection drops, so by the time