@jini-ai/chat 0.6.6 → 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 (156) hide show
  1. package/CHANGELOG.md +4 -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 +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.js +47 -31
  77. package/dist/react/components/MessageRow.usage.js +16 -11
  78. package/dist/react/components/MidRunUserMessage.js +16 -3
  79. package/dist/react/components/QuestionForm.js +148 -76
  80. package/dist/react/components/QuestionsPanel.js +1 -1
  81. package/dist/react/components/TodoCard.js +12 -6
  82. package/dist/react/components/ToolCard.js +47 -40
  83. package/dist/react/components/composer-discovery.js +6 -1
  84. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  85. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  86. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  87. package/dist/react/ext-event-renderer-registry.js +5 -2
  88. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  89. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  90. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  91. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  92. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  93. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  94. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  95. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  96. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  97. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  98. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  99. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  100. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  101. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  102. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  103. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  104. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  105. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  106. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  107. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  108. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  109. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  110. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  111. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  112. package/dist/react/features/chat-pane/model-options.js +45 -25
  113. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  114. package/dist/react/features/chat-pane/rules.js +84 -46
  115. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  116. package/dist/react/features/chat-pane/styles.js +152 -36
  117. package/dist/react/features/chat-pane/types.d.ts +17 -8
  118. package/dist/react/hooks/assistant-chats.js +12 -5
  119. package/dist/react/hooks/composer-draft-cache.js +116 -118
  120. package/dist/react/hooks/composer-history-storage.js +5 -5
  121. package/dist/react/hooks/fab-position.js +7 -4
  122. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  123. package/dist/react/hooks/useAnsweredCards.js +38 -11
  124. package/dist/react/hooks/useArtifactStream.js +28 -22
  125. package/dist/react/hooks/useComposer.d.ts +20 -0
  126. package/dist/react/hooks/useComposer.js +22 -9
  127. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  128. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  129. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  130. package/dist/react/hooks/useConversation.d.ts +7 -0
  131. package/dist/react/hooks/useConversation.js +23 -10
  132. package/dist/react/hooks/useExtEventGroups.js +39 -39
  133. package/dist/react/hooks/useQuestionForms.js +21 -26
  134. package/dist/react/hooks/useRunStream.js +10 -2
  135. package/dist/react/index.d.ts +7 -1
  136. package/dist/react/index.js +9 -0
  137. package/dist/react/message-blocks.d.ts +6 -4
  138. package/dist/react/message-blocks.js +94 -72
  139. package/dist/react/slots.d.ts +2 -2
  140. package/dist/react/tool-call-display.js +19 -17
  141. package/dist/server/durable-runs/recover.js +132 -80
  142. package/dist/server/run-finalizer/entry.js +25 -15
  143. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  144. package/dist/store/legacy/sqlite/conversations.js +69 -98
  145. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  146. package/dist/store/legacy/sqlite/messages.js +113 -71
  147. package/dist/store/runtime/chat-history-store.js +9 -3
  148. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  149. package/dist/store/runtime/durable-run-store.js +165 -117
  150. package/dist/store/runtime/history-tools.js +118 -82
  151. package/dist/store/runtime/run-ledger.d.ts +4 -1
  152. package/dist/store/runtime/run-ledger.js +56 -16
  153. package/dist/store/sql/cursor.js +38 -18
  154. package/dist/store/sql/store.d.ts +18 -0
  155. package/dist/store/sql/store.js +46 -15
  156. package/package.json +12 -10
@@ -10,25 +10,49 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  * scroll-anchoring state lives in `useMessageListAutoScroll`
11
11
  * (`MessageList.hooks.ts`); see that file for why it is presentational.
12
12
  */
13
- import { memo } from 'react';
13
+ import { memo, useMemo } from 'react';
14
+ import { projectAcceptedInputMessages } from '../../core/accepted-input.js';
14
15
  import { isTerminalRunStatus } from '../../core/index.js';
15
16
  import { useT } from '../hooks/context.js';
16
17
  import { useMessageListAutoScroll, useConversationUsage } from './MessageList.hooks.js';
17
18
  import { MessageRow } from './MessageRow.js';
19
+ import { SentAttachmentButton } from './SentAttachment.js';
20
+ import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
21
+ import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
22
+ function renderMessageRow({ message, isLast, isStreaming, usageSummary }, { activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, ...rowProps } = {}) {
23
+ const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
24
+ return (_jsx(MessageRow, { message: message, ...rowProps, ...(usageSummary === undefined ? {} : { usageSummary }), runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}) }, message.id));
25
+ }
18
26
  /**
19
27
  * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
20
28
  * (hundreds of tool rows, MCP-UI cards, markdown) per key made typing lag (owner report
21
- * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompt` and
29
+ * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompts` and
22
30
  * `projectFileNames` for exactly this.
23
31
  */
24
- export const MessageList = memo(function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, showUsageTotal = true, showUsageLine = true, toolCallDisplay = 'show', showThinking = false, pendingPrompt = null, }) {
25
- const t = useT();
32
+ export const MessageList = memo(function MessageList({ messages: sourceMessages, isStreaming = false, scrollIntent = false, onScrolled, pendingPrompt, pendingPrompts, ...rowProps }) {
33
+ const messages = useMemo(() => projectAcceptedInputMessages({ messages: sourceMessages }), [sourceMessages]);
26
34
  const usageByMessage = useConversationUsage({ messages });
27
- const { containerRef, onScroll } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
28
- return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
29
- const isLast = message.id === messages[messages.length - 1]?.id;
30
- const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
31
- return (_jsx(MessageRow, { message: message, ...(usageByMessage.has(message.id) ? { usageSummary: usageByMessage.get(message.id) } : {}), showUsageTotal: showUsageTotal, showUsageLine: showUsageLine, toolCallDisplay: toolCallDisplay, showThinking: showThinking, runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}), ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}), ...(renderAttachment !== undefined ? { renderAttachment } : {}) }, message.id));
32
- }), pendingPrompt ? (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { className: "jini-chat-pane__queued-text", children: pendingPrompt.text }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: pendingPrompt.onCancel, title: t('Cancel queued message'), children: t('Cancel') }) })] })) : null] }));
35
+ const { containerRef, onScroll, awayFromLatest, jumpToLatest } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
36
+ return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => renderMessageRow({
37
+ message,
38
+ isLast: message.id === messages[messages.length - 1]?.id,
39
+ isStreaming,
40
+ usageSummary: usageByMessage.get(message.id),
41
+ }, rowProps)), (pendingPrompts ?? (pendingPrompt ? [pendingPrompt] : [])).map((prompt, index) => (_jsx(QueuedMessage, { prompt: prompt, renderAttachment: rowProps.renderAttachment }, prompt.id ?? index))), awayFromLatest && _jsx(JumpToLatest, { onJump: jumpToLatest })] }));
33
42
  });
43
+ /**
44
+ * Sticky, zero-height last child of the scroll container, so the button floats centred just above
45
+ * the composer without a wrapper element changing the pane's flex layout.
46
+ */
47
+ function JumpToLatest({ onJump }) {
48
+ const t = useT();
49
+ return (_jsx("div", { className: "jini-message-list__jump", children: _jsxs("button", { type: "button", className: "jini-message-list__jump-btn", onClick: onJump, children: [_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M12 5v14" }), _jsx("path", { d: "m19 12-7 7-7-7" })] }), t('Jump to latest')] }) }));
50
+ }
51
+ /** One cancelable queue receipt; attachment rendering reuses the sent-message preview owner. */
52
+ function QueuedMessage({ prompt, renderAttachment }) {
53
+ const t = useT();
54
+ const attachments = prompt.attachments ?? [];
55
+ const gallery = useAttachmentGallery({ attachments });
56
+ return (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { children: t('Queued — sends when this reply finishes') }), _jsx("div", { className: "jini-chat-pane__queued-text", children: prompt.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open, renderAttachment: renderAttachment }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: () => prompt.onCancel(), title: t('Cancel queued message'), children: t('Cancel') }) })] }));
57
+ }
34
58
  //# sourceMappingURL=MessageList.js.map
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
3
+ import { MidRunUserMessage, MID_RUN_USER_MESSAGE_EXT_EVENT_NAME } from './MidRunUserMessage.js';
2
4
  import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
3
5
  import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
4
6
  /**
@@ -51,7 +53,7 @@ import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary }
51
53
  * not appear until the whole message is done, not merely until its current
52
54
  * chunk stopped arriving.
53
55
  */
54
- import React, { memo } from 'react';
56
+ import React, { memo, useMemo } from 'react';
55
57
  import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
56
58
  import { useToolTimeline } from '../hooks/useToolTimeline.js';
57
59
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
@@ -164,24 +166,31 @@ const NO_ATTACHMENTS = [];
164
166
  * re-renders per composer keystroke) only re-renders rows whose props actually changed. Messages
165
167
  * are replaced, never mutated, so identity is a sound change signal.
166
168
  */
167
- export const MessageRow = memo(function MessageRow({ message, usageSummary, showUsageTotal = true, showUsageLine = true, toolCallDisplay = 'show', showThinking = false, runStreaming = false, runSucceeded = false, questionFormInteractive = false, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, }) {
169
+ export const MessageRow = memo(function MessageRow({ showUsageTotal = true, showUsageLine = true, toolCallDisplay = 'show', showThinking = false, runStreaming = false, runSucceeded = false, questionFormInteractive = false, ...props }) {
170
+ const message = useMemo(() => props.message.events === undefined ? props.message : {
171
+ ...props.message, events: projectAcceptedInputEvents({ events: props.message.events }),
172
+ }, [props.message]);
173
+ if (message.role === 'user')
174
+ return _jsx(UserMessageRow, { ...props, message: message });
175
+ return _jsx(AssistantMessageRow, { ...props, message: message, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSucceeded, questionFormInteractive });
176
+ });
177
+ function UserMessageRow({ message, renderAttachment }) {
168
178
  const t = useT();
169
- const registry = useExtEventRendererRegistry();
170
- const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
171
- const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
172
- const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => registry.slot({ name, data }) });
173
- const answeredCards = useAnsweredCards({ slotOf: registry.slot });
174
179
  // Which attachment chip (if any) this row's preview modal is open for. Row-local, not
175
180
  // module-level: two attachments in the same message can never be "open" at once, but two
176
181
  // different messages opening their own attachments independently is fine and expected.
177
182
  // Previous/next steps through this message's images only.
178
183
  const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
179
- if (message.role === 'user') {
180
- return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
181
- }
184
+ return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
185
+ }
186
+ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
187
+ const registry = useExtEventRendererRegistry();
188
+ const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
189
+ const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
190
+ const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => registry.slot({ name, data }) });
191
+ const answeredCards = useAnsweredCards({ slotOf: registry.slot, events: message.events ?? [], groups: extGroups, runStreaming, runStatus: message.runStatus });
182
192
  const terminalNotice = terminalMessageNotice({ message }, {});
183
193
  const visibleContent = stripArtifact({ content: message.content });
184
- const segments = splitOnQuestionForms({ input: visibleContent });
185
194
  const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
186
195
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
187
196
  const runInProgress = isRunInProgress(message.runStatus);
@@ -196,18 +205,24 @@ export const MessageRow = memo(function MessageRow({ message, usageSummary, show
196
205
  const flatToolRows = toolRowsForDisplay({ rows: timeline.rows, mode: toolCallDisplay });
197
206
  const renderExtGroup = (group) => {
198
207
  const renderer = registry.get({ name: group.name });
208
+ if (!renderer && group.name === MID_RUN_USER_MESSAGE_EXT_EVENT_NAME)
209
+ return _jsx(MidRunUserMessage, { events: group.events });
199
210
  if (!renderer)
200
211
  return null;
212
+ const answerText = answeredCards.answerText(group.slot);
201
213
  const node = renderer({
202
214
  name: group.name,
203
215
  events: group.events,
204
- runStreaming,
216
+ runStreaming: answeredCards.runStreaming,
205
217
  runSucceeded,
206
218
  awaitingAnswer,
207
219
  runId: message.runId,
208
220
  ...(group.call === undefined ? {} : { call: group.call }),
209
221
  answered: answeredCards.isAnswered(group.slot),
210
222
  onAnswered: () => answeredCards.markAnswered(group.slot),
223
+ onAnswerText: (text) => answeredCards.markAnswered(group.slot, text),
224
+ ...(answeredCards.isRecorded(group.slot) ? { answerRecorded: true } : {}),
225
+ ...(answerText === undefined ? {} : { answerText }),
211
226
  });
212
227
  if (!node)
213
228
  return null;
@@ -218,28 +233,29 @@ export const MessageRow = memo(function MessageRow({ message, usageSummary, show
218
233
  _jsx(ExtEventErrorBoundary, { name: group.name, children: _jsx("div", { children: node }) }, `${group.slot}:${group.events.length}`));
219
234
  };
220
235
  const renderTextSegments = (text, keyPrefix) => splitOnQuestionForms({ input: text }).map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, `${keyPrefix}-${i}`)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, `${keyPrefix}-${i}`)));
236
+ const renderBlock = (block) => {
237
+ if (block.kind === 'text') {
238
+ return _jsx(React.Fragment, { children: renderTextSegments(block.text, block.key) }, block.key);
239
+ }
240
+ if (block.kind === 'reply-end') {
241
+ const reply = usage.replies[block.index];
242
+ return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal }, block.key) : null;
243
+ }
244
+ if (block.kind === 'tools')
245
+ return renderTools(block.rows, block.key);
246
+ if (block.kind === 'thinking')
247
+ return _jsx(ThinkingBlock, { text: block.text }, block.key);
248
+ const group = extGroups.find((g) => g.slot === block.slot);
249
+ if (!group)
250
+ return null;
251
+ return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
252
+ };
221
253
  return (_jsxs("div", { className: usageLineMeetsActions({ footer: footerUsage, terminalNotice, pending, runInProgress }, { showTotal: showUsageTotal })
222
254
  ? 'jini-message jini-message-assistant jini-message-assistant--usage-actions'
223
255
  : 'jini-message jini-message-assistant', "data-message-id": message.id, "data-run-status": message.runStatus, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A reply from the assistant", children: [message.agentName ? _jsx("div", { className: "jini-message-agent", children: message.agentName }) : null, blocks
224
- ? blocks.map((block) => {
225
- if (block.kind === 'text') {
226
- return _jsx(React.Fragment, { children: renderTextSegments(block.text, block.key) }, block.key);
227
- }
228
- if (block.kind === 'reply-end') {
229
- const reply = usage.replies[block.index];
230
- return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal }, block.key) : null;
231
- }
232
- if (block.kind === 'tools')
233
- return renderTools(block.rows, block.key);
234
- if (block.kind === 'thinking')
235
- return _jsx(ThinkingBlock, { text: block.text }, block.key);
236
- const group = extGroups.find((g) => g.slot === block.slot);
237
- if (!group)
238
- return null;
239
- return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
240
- })
241
- : (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), flatToolRows.length > 0 ? renderTools(flatToolRows) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent: footerUsage, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard: answeredCards.isAnsweredCard })] }));
242
- });
256
+ ? blocks.map(renderBlock)
257
+ : (_jsxs(_Fragment, { children: [renderTextSegments(visibleContent, 'text'), flatToolRows.length > 0 ? renderTools(flatToolRows) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent: footerUsage, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard: answeredCards.isAnsweredCard })] }));
258
+ }
243
259
  /**
244
260
  * The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
245
261
  * old "Thinking…" placeholder, which vanished for good as soon as the first tool row appeared.
@@ -33,19 +33,24 @@ export function usageLineMeetsActions({ footer, terminalNotice, pending, runInPr
33
33
  * the current turn's cost is unknown. `showTotal: false` turns the total off. */
34
34
  export function useUsageSummary({ usage }, { showTotal = true } = {}) {
35
35
  const t = useT();
36
- const parts = [];
37
- if (usage.durationMs !== undefined) {
38
- const seconds = Math.round(usage.durationMs / 1000);
39
- parts.push(seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`);
40
- }
41
- if (usage.costUsd !== undefined) {
42
- parts.push(formatUsageCost({ costUsd: usage.costUsd, ...(showTotal ? { totalCostUsd: usage.totalCostUsd ?? usage.costUsd } : {}) }));
43
- }
44
- else if (showTotal && usage.totalCostUsd !== undefined) {
45
- parts.push(`total ${formatUsageCost({ costUsd: usage.totalCostUsd })}`);
46
- }
36
+ const parts = [usageDuration({ durationMs: usage.durationMs }), usageCost({ usage }, { showTotal })]
37
+ .filter((part) => part !== undefined);
47
38
  const text = parts.join(' · ');
48
39
  const title = usage.outputTokens === undefined ? undefined : t('{n} out', { n: usage.outputTokens });
49
40
  return { text, title, ariaLabel: title === undefined ? undefined : `${t('Done')} · ${text} · ${title}` };
50
41
  }
42
+ function usageDuration({ durationMs }, _options = {}) {
43
+ if (durationMs === undefined)
44
+ return undefined;
45
+ const seconds = Math.round(durationMs / 1000);
46
+ return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
47
+ }
48
+ function usageCost({ usage }, { showTotal = true } = {}) {
49
+ if (usage.costUsd !== undefined) {
50
+ return formatUsageCost({ costUsd: usage.costUsd, ...(showTotal ? { totalCostUsd: usage.totalCostUsd ?? usage.costUsd } : {}) });
51
+ }
52
+ if (showTotal && usage.totalCostUsd !== undefined)
53
+ return `total ${formatUsageCost({ costUsd: usage.totalCostUsd })}`;
54
+ return undefined;
55
+ }
51
56
  //# sourceMappingURL=MessageRow.usage.js.map
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * @module MidRunUserMessage
4
4
  *
@@ -10,6 +10,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  * It is an event of the assistant turn, not a `ChatMessage` of its own, because the agent folded it
11
11
  * into the turn it was already running — there is no separate reply to hang off it.
12
12
  */
13
+ import { acceptedInputEvent } from '../../core/accepted-input.js';
14
+ import { SentAttachmentButton } from './SentAttachment.js';
15
+ import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
16
+ import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
13
17
  import { MID_RUN_USER_MESSAGE_EVENT_NAME } from '../../core/turn-usage.js';
14
18
  import { registerExtEventRenderer } from '../ext-event-renderer-registry.js';
15
19
  /** The ext-event name the daemon's mid-run `user_message` run event arrives under. One source with
@@ -25,10 +29,19 @@ export function midRunUserMessageSlotKey({ data }) {
25
29
  }
26
30
  /** The bubble for one mid-run message — the same classes as a sent user message, so it reads as one. */
27
31
  export function MidRunUserMessage({ events }) {
28
- const text = field(events.at(-1), 'text');
32
+ const data = events.at(-1);
33
+ const text = field(data, 'text');
29
34
  if (text === undefined)
30
35
  return null;
31
- return (_jsx("div", { className: "jini-message jini-message-user jini-message-user--mid-run", "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: _jsx("div", { className: "jini-message-content", children: text }) }));
36
+ const receipt = acceptedInputEvent({ event: { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME, data } });
37
+ // MOVE: A single live delivery may contain several queued inputs; display their original receipts.
38
+ const inputs = receipt?.data.inputs ?? [{ id: field(data, 'id') ?? 'mid-run', text }];
39
+ return _jsx(_Fragment, { children: inputs.map(input => _jsx(MidRunInput, { input: input }, input.id)) });
40
+ }
41
+ function MidRunInput({ input }) {
42
+ const attachments = input.attachments ?? [];
43
+ const gallery = useAttachmentGallery({ attachments });
44
+ return (_jsxs("div", { className: "jini-message jini-message-user jini-message-user--mid-run", "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [_jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
32
45
  }
33
46
  /**
34
47
  * Registers {@link MidRunUserMessage} against the ext-event registry.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { CREDENTIAL_CARD_GUIDANCE, SECRET_REDACTED_NOTICE } from '../../core/user-text-redaction.js';
3
3
  import { useUserTextGuard } from '../hooks/useUserTextGuard.js';
4
4
  /**
@@ -21,34 +21,46 @@ export const QuestionForm = forwardRef(function QuestionForm({ form: incomingFor
21
21
  const t = useT();
22
22
  const form = useMemo(() => withoutSecretQuestionDefaults({ form: incomingForm }), [incomingForm]);
23
23
  const guard = useUserTextGuard({ ...(redactUserText ? { redactUserText } : {}), ...(onSecretRedacted ? { onSecretRedacted } : {}) });
24
+ const locked = !interactive || !onSubmit || submittedAnswers !== undefined;
25
+ const state = useQuestionAnswers({ form, submittedAnswers, draftAnswers, locked }, { onDraftChange, onAnswerChange });
26
+ const currentAnswers = submittedAnswers ? redactQuestionAnswers({ form, answers: submittedAnswers }).answers : state.answers;
27
+ const ready = answersReady(form, currentAnswers);
28
+ function handleSubmit() {
29
+ if (locked || !onSubmit)
30
+ return;
31
+ if (!ready)
32
+ return;
33
+ const safe = redactQuestionAnswers({ form, answers: state.answers }, { ...(redactUserText ? { redactUserText } : {}), onSecretRedacted: guard.notifyRedaction });
34
+ state.setAnswers(safe.answers);
35
+ const files = collectFileSubmissions(form, state.fileAnswers);
36
+ if (files.length > 0)
37
+ onSubmit(formatFormAnswers({ form: form, answers: safe.answers }), safe.answers, files);
38
+ else
39
+ onSubmit(formatFormAnswers({ form: form, answers: safe.answers }), safe.answers);
40
+ }
41
+ function handleSkipAll() {
42
+ if (locked || !onSubmit)
43
+ return;
44
+ const empty = {};
45
+ onSubmit(formatFormAnswers({ form: form, answers: empty }), empty);
46
+ }
47
+ useImperativeHandle(ref, () => ({ submit: handleSubmit, skipAll: handleSkipAll }));
48
+ useEffect(() => {
49
+ onReadyChange?.(!locked && ready);
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [onReadyChange, locked, ready]);
52
+ return (_jsxs("div", { className: `question-form${locked ? ' question-form-locked' : ''}`, "data-form-id": form.id, children: [_jsx(QuestionFormHead, { form: form, locked: locked }), _jsx("div", { className: "question-form-body", children: form.questions.map((q) => (_jsx(QuestionField, { q: q, value: currentAnswers[q.id], formId: form.id, locked: locked, t: t, ...state.handlers }, q.id))) }), guard.secretRedacted ? _jsx("div", { role: "status", children: t(SECRET_REDACTED_NOTICE) }) : null, hideInternalSubmit ? null : _jsx(QuestionFormFoot, { locked: locked, answered: submittedAnswers !== undefined, ready: ready, submitLabel: form.submitLabel, onSubmit: handleSubmit })] }));
53
+ });
54
+ /** Owns the editable answers plus the picked files; every edit is a no-op once the form is locked. */
55
+ function useQuestionAnswers({ form, submittedAnswers, draftAnswers, locked }, { onDraftChange, onAnswerChange } = {}) {
24
56
  const initial = useMemo(() => buildInitialState(form, submittedAnswers, draftAnswers), [form, submittedAnswers, draftAnswers]);
25
57
  const [answers, setAnswers] = useState(initial);
26
58
  const [fileAnswers, setFileAnswers] = useState({});
27
- const locked = !interactive || !onSubmit || submittedAnswers !== undefined;
28
- const currentAnswers = submittedAnswers ? redactQuestionAnswers({ form, answers: submittedAnswers }).answers : answers;
29
59
  useEffect(() => {
30
60
  setFileAnswers({});
31
61
  }, [form.id]);
32
62
  useEffect(() => {
33
- setAnswers((prev) => {
34
- let changed = false;
35
- const next = { ...prev };
36
- for (const q of form.questions) {
37
- if (next[q.id] !== undefined)
38
- continue;
39
- changed = true;
40
- if (submittedAnswers && submittedAnswers[q.id] !== undefined) {
41
- next[q.id] = canonicalizeQuestionValue(q, submittedAnswers[q.id]);
42
- }
43
- else if (q.defaultValue !== undefined) {
44
- next[q.id] = canonicalizeQuestionValue(q, q.defaultValue);
45
- }
46
- else {
47
- next[q.id] = emptyQuestionValue(q);
48
- }
49
- }
50
- return changed ? next : prev;
51
- });
63
+ setAnswers((prev) => backfillAnswers(prev, form, submittedAnswers));
52
64
  }, [form, submittedAnswers]);
53
65
  function update(id, value) {
54
66
  if (locked)
@@ -78,60 +90,111 @@ export const QuestionForm = forwardRef(function QuestionForm({ form: incomingFor
78
90
  const fixed = current.filter((entry) => questionValueIsKnown(q, entry));
79
91
  update(q.id, [...fixed, ...splitCustomEntries(raw)]);
80
92
  }
81
- function handleSubmit() {
82
- if (locked || !onSubmit)
83
- return;
84
- if (!ready)
85
- return;
86
- const safe = redactQuestionAnswers({ form, answers }, { ...(redactUserText ? { redactUserText } : {}), onSecretRedacted: guard.notifyRedaction });
87
- setAnswers(safe.answers);
88
- const files = collectFileSubmissions(form, fileAnswers);
89
- if (files.length > 0)
90
- onSubmit(formatFormAnswers({ form: form, answers: safe.answers }), safe.answers, files);
91
- else
92
- onSubmit(formatFormAnswers({ form: form, answers: safe.answers }), safe.answers);
93
- }
94
- function handleSkipAll() {
95
- if (locked || !onSubmit)
96
- return;
97
- const empty = {};
98
- onSubmit(formatFormAnswers({ form: form, answers: empty }), empty);
93
+ function selectFiles(q, list) {
94
+ const files = Array.from(list ?? []);
95
+ const names = files.map((file) => file.name);
96
+ setFileAnswers((current) => ({ ...current, [q.id]: files }));
97
+ update(q.id, q.multiple ? names : (names[0] ?? ''));
99
98
  }
99
+ return { answers, setAnswers, fileAnswers, handlers: { update, toggleCheckbox, updateCheckboxCustom, selectFiles } };
100
+ }
101
+ function answersReady(form, answers) {
100
102
  const withinSelectionLimits = form.questions.every((q) => {
101
103
  if (q.type !== 'checkbox' || q.maxSelections === undefined)
102
104
  return true;
103
- const v = currentAnswers[q.id];
105
+ const v = answers[q.id];
104
106
  return !Array.isArray(v) || v.length <= q.maxSelections;
105
107
  });
106
108
  const requiredAnswered = form.questions.every((q) => {
107
109
  if (q.required !== true)
108
110
  return true;
109
- const v = currentAnswers[q.id];
111
+ const v = answers[q.id];
110
112
  if (Array.isArray(v))
111
113
  return v.length > 0;
112
114
  return typeof v === 'string' && v.trim().length > 0;
113
115
  });
114
- const ready = withinSelectionLimits && requiredAnswered;
115
- useImperativeHandle(ref, () => ({ submit: handleSubmit, skipAll: handleSkipAll }));
116
- useEffect(() => {
117
- onReadyChange?.(!locked && ready);
118
- // eslint-disable-next-line react-hooks/exhaustive-deps
119
- }, [onReadyChange, locked, ready]);
120
- return (_jsxs("div", { className: `question-form${locked ? ' question-form-locked' : ''}`, "data-form-id": form.id, children: [_jsxs("div", { className: "question-form-head", children: [_jsx("span", { className: "question-form-icon", "aria-hidden": true, children: "?" }), _jsxs("div", { className: "question-form-titles", children: [_jsx("div", { className: "question-form-title", children: form.title }), form.description ? _jsx("div", { className: "question-form-desc", children: form.description }) : null] }), locked ? _jsx("span", { className: "question-form-pill", children: t('Answered') }) : null] }), _jsx("div", { className: "question-form-body", children: form.questions.map((q) => {
121
- const value = currentAnswers[q.id];
122
- return (_jsxs("div", { className: "qf-field", children: [_jsxs("label", { className: "qf-label", children: [_jsx("span", { children: q.label }), q.required ? (_jsx("span", { className: "qf-required", "aria-label": t('required'), children: "*" })) : null] }), q.help ? _jsx("div", { className: "qf-help", children: q.help }) : null, isSecretQuestion({ question: q }) ? _jsx("div", { className: "qf-help", children: t(CREDENTIAL_CARD_GUIDANCE) }) : null, q.type === 'radio' && q.options ? (_jsx("div", { className: "qf-options", children: q.options.map((opt) => (_jsxs("label", { className: `qf-chip${value === opt.value ? ' qf-chip-on' : ''}`, title: opt.description, children: [_jsx("input", { type: "radio", name: `${form.id}-${q.id}`, value: opt.value, checked: value === opt.value, disabled: locked, "aria-label": opt.label, onChange: () => update(q.id, opt.value) }), _jsx(OptionCopy, { option: opt })] }, opt.value))) })) : null, q.type === 'radio' && q.options && shouldRenderCustomChoice(q) ? (_jsx(CustomChoiceInput, { label: q.customLabel ?? t('Something else'), value: customSingleValue(q, value), placeholder: q.customPlaceholder ?? t('Type your own answer'), disabled: locked, onChange: (next) => update(q.id, next) })) : null, q.type === 'checkbox' && q.options ? (_jsx("div", { className: "qf-options", children: q.options.map((opt) => {
123
- const arr = Array.isArray(value) ? value : [];
124
- const on = arr.includes(opt.value);
125
- const maxed = q.maxSelections !== undefined && !on && arr.length >= q.maxSelections;
126
- return (_jsxs("label", { title: opt.description, className: `qf-chip${on ? ' qf-chip-on' : ''}${maxed ? ' qf-chip-disabled' : ''}`, children: [_jsx("input", { type: "checkbox", value: opt.value, checked: on, disabled: locked || maxed, "aria-label": opt.label, onChange: () => toggleCheckbox(q.id, opt.value, q.maxSelections) }), _jsx(OptionCopy, { option: opt })] }, opt.value));
127
- }) })) : null, q.type === 'checkbox' && q.options && shouldRenderCustomChoice(q) ? (_jsx(CustomChoiceInput, { label: q.customLabel ?? t('Something else'), value: customCheckboxValue(q, value), placeholder: q.customPlaceholder ?? t('Type your own answer'), disabled: locked, onChange: (next) => updateCheckboxCustom(q, next) })) : null, q.type === 'select' && q.options ? (_jsxs("select", { className: "qf-select", value: typeof value === 'string' && questionValueIsKnown(q, value) ? value : '', disabled: locked, onChange: (e) => update(q.id, e.target.value), children: [_jsx("option", { value: "", disabled: true, children: q.placeholder ?? t('Choose one') }), q.options.map((opt) => (_jsx("option", { value: opt.value, title: opt.description, children: opt.label }, opt.value)))] })) : null, q.type === 'select' && q.options && shouldRenderCustomChoice(q) ? (_jsx(CustomChoiceInput, { label: q.customLabel ?? t('Something else'), value: customSingleValue(q, value), placeholder: q.customPlaceholder ?? t('Type your own answer'), disabled: locked, onChange: (next) => update(q.id, next) })) : null, q.type === 'text' ? _jsx("input", { ...questionTextInputProps({ question: q }), className: "qf-input", value: typeof value === 'string' ? value : '', placeholder: q.placeholder, disabled: locked, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'number' ? _jsx("input", { type: "number", className: "qf-input", value: typeof value === 'string' ? value : '', placeholder: q.placeholder, min: q.min, max: q.max, step: q.step, disabled: locked, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'range' ? (_jsxs("div", { className: "qf-range-wrap", children: [_jsx("input", { type: "range", className: "qf-range", value: typeof value === 'string' && value.trim() ? value : String(q.min ?? 0), min: q.min, max: q.max, step: q.step, disabled: locked, onChange: (e) => update(q.id, e.target.value) }), _jsx("output", { className: "qf-range-value", children: typeof value === 'string' && value.trim() ? value : String(q.min ?? 0) })] })) : null, q.type === 'date' || q.type === 'time' || q.type === 'datetime-local' ? _jsx("input", { type: q.type, className: "qf-input", value: typeof value === 'string' ? value : '', placeholder: q.placeholder, disabled: locked, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'color' ? _jsx("input", { type: "color", className: "qf-color", value: normalizeColorInputValue(value), disabled: locked, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'url' || q.type === 'email' || q.type === 'tel' ? _jsx("input", { type: q.type, className: "qf-input", value: typeof value === 'string' ? value : '', placeholder: q.placeholder, disabled: locked, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'file' ? (_jsxs("div", { className: "qf-file-wrap", children: [_jsx("input", { type: "file", className: "qf-file", multiple: q.multiple, accept: q.accept, disabled: locked, onChange: (e) => {
128
- const files = Array.from(e.target.files ?? []);
129
- const names = files.map((file) => file.name);
130
- setFileAnswers((current) => ({ ...current, [q.id]: files }));
131
- update(q.id, q.multiple ? names : (names[0] ?? ''));
132
- } }), fileValueLabel(value) ? _jsx("div", { className: "qf-file-summary", children: fileValueLabel(value) }) : null] })) : null, q.type === 'switch' ? (_jsxs("label", { className: "qf-switch", children: [_jsx("input", { type: "checkbox", role: "switch", checked: value === 'true', disabled: locked, onChange: (e) => update(q.id, e.target.checked ? 'true' : 'false') }), _jsx("span", { "aria-hidden": true })] })) : null, q.type === 'textarea' ? _jsx("textarea", { ...questionTextareaProps({ question: q }), className: "qf-textarea", value: typeof value === 'string' ? value : '', placeholder: q.placeholder, disabled: locked, rows: 3, onChange: (e) => update(q.id, e.target.value) }) : null, q.type === 'direction-cards' && q.cards && q.cards.length > 0 ? (_jsx("div", { className: "qf-direction-cards", children: q.cards.map((card) => (_jsx(DirectionCardView, { card: card, formId: form.id, questionId: q.id, selected: value === card.id || value === card.label, disabled: locked, onSelect: () => update(q.id, card.id) }, card.id))) })) : null, q.type === 'direction-cards' && q.cards && q.cards.length > 0 && shouldRenderCustomChoice(q) ? (_jsx(CustomChoiceInput, { label: q.customLabel ?? t('Something else'), value: customSingleValue(q, value), placeholder: q.customPlaceholder ?? t('Type your own answer'), disabled: locked, onChange: (next) => update(q.id, next) })) : null] }, q.id));
133
- }) }), guard.secretRedacted ? _jsx("div", { role: "status", children: t(SECRET_REDACTED_NOTICE) }) : null, hideInternalSubmit ? null : (_jsxs("div", { className: "question-form-foot", children: [locked ? _jsx("span", { className: "qf-locked-note", children: submittedAnswers ? t('You answered this') : t('A newer message answered this') }) : _jsx("span", { className: "qf-hint", children: t('Answer above, then continue') }), !locked ? (_jsx("button", { type: "button", className: "primary", onClick: handleSubmit, disabled: !ready, title: ready ? t('Submit your answers') : t('Answer the required questions first'), children: form.submitLabel ?? t('Continue') })) : null] }))] }));
134
- });
116
+ return withinSelectionLimits && requiredAnswered;
117
+ }
118
+ function QuestionFormHead({ form, locked }) {
119
+ const t = useT();
120
+ return (_jsxs("div", { className: "question-form-head", children: [_jsx("span", { className: "question-form-icon", "aria-hidden": true, children: "?" }), _jsxs("div", { className: "question-form-titles", children: [_jsx("div", { className: "question-form-title", children: form.title }), form.description ? _jsx("div", { className: "question-form-desc", children: form.description }) : null] }), locked ? _jsx("span", { className: "question-form-pill", children: t('Answered') }) : null] }));
121
+ }
122
+ function QuestionFormFoot({ locked, answered, ready, submitLabel, onSubmit }) {
123
+ const t = useT();
124
+ return (_jsxs("div", { className: "question-form-foot", children: [locked ? _jsx("span", { className: "qf-locked-note", children: answered ? t('You answered this') : t('A newer message answered this') }) : _jsx("span", { className: "qf-hint", children: t('Answer above, then continue') }), !locked ? (_jsx("button", { type: "button", className: "primary", onClick: onSubmit, disabled: !ready, title: ready ? t('Submit your answers') : t('Answer the required questions first'), children: submitLabel ?? t('Continue') })) : null] }));
125
+ }
126
+ function QuestionField(ctx) {
127
+ const { q, t } = ctx;
128
+ const renderControl = QUESTION_CONTROLS[q.type];
129
+ return (_jsxs("div", { className: "qf-field", children: [_jsxs("label", { className: "qf-label", children: [_jsx("span", { children: q.label }), q.required ? (_jsx("span", { className: "qf-required", "aria-label": t('required'), children: "*" })) : null] }), q.help ? _jsx("div", { className: "qf-help", children: q.help }) : null, isSecretQuestion({ question: q }) ? _jsx("div", { className: "qf-help", children: t(CREDENTIAL_CARD_GUIDANCE) }) : null, renderControl ? renderControl(ctx) : null] }));
130
+ }
131
+ function stringValue(value) {
132
+ return typeof value === 'string' ? value : '';
133
+ }
134
+ /** A range with no usable answer yet (e.g. mid-stream) shows its minimum, matching `emptyQuestionValue`. */
135
+ function rangeValue(q, value) {
136
+ return typeof value === 'string' && value.trim() ? value : String(q.min ?? 0);
137
+ }
138
+ function customChoice({ q, t, locked }, value, onChange) {
139
+ if (!shouldRenderCustomChoice(q))
140
+ return null;
141
+ return _jsx(CustomChoiceInput, { label: q.customLabel ?? t('Something else'), value: value, placeholder: q.customPlaceholder ?? t('Type your own answer'), disabled: locked, onChange: onChange });
142
+ }
143
+ function singleCustomChoice(ctx) {
144
+ return customChoice(ctx, customSingleValue(ctx.q, ctx.value), (next) => ctx.update(ctx.q.id, next));
145
+ }
146
+ function RadioControl(ctx) {
147
+ const { q, value, formId, locked, update } = ctx;
148
+ if (!q.options)
149
+ return null;
150
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "qf-options", children: q.options.map((opt) => (_jsxs("label", { className: `qf-chip${value === opt.value ? ' qf-chip-on' : ''}`, title: opt.description, children: [_jsx("input", { type: "radio", name: `${formId}-${q.id}`, value: opt.value, checked: value === opt.value, disabled: locked, "aria-label": opt.label, onChange: () => update(q.id, opt.value) }), _jsx(OptionCopy, { option: opt })] }, opt.value))) }), singleCustomChoice(ctx)] }));
151
+ }
152
+ function CheckboxControl(ctx) {
153
+ const { q, value, locked, toggleCheckbox, updateCheckboxCustom } = ctx;
154
+ if (!q.options)
155
+ return null;
156
+ const arr = Array.isArray(value) ? value : [];
157
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "qf-options", children: q.options.map((opt) => {
158
+ const on = arr.includes(opt.value);
159
+ const maxed = q.maxSelections !== undefined && !on && arr.length >= q.maxSelections;
160
+ return (_jsxs("label", { title: opt.description, className: `qf-chip${on ? ' qf-chip-on' : ''}${maxed ? ' qf-chip-disabled' : ''}`, children: [_jsx("input", { type: "checkbox", value: opt.value, checked: on, disabled: locked || maxed, "aria-label": opt.label, onChange: () => toggleCheckbox(q.id, opt.value, q.maxSelections) }), _jsx(OptionCopy, { option: opt })] }, opt.value));
161
+ }) }), customChoice(ctx, customCheckboxValue(q, value), (next) => updateCheckboxCustom(q, next))] }));
162
+ }
163
+ function SelectControl(ctx) {
164
+ const { q, value, locked, t, update } = ctx;
165
+ if (!q.options)
166
+ return null;
167
+ return (_jsxs(_Fragment, { children: [_jsxs("select", { className: "qf-select", value: typeof value === 'string' && questionValueIsKnown(q, value) ? value : '', disabled: locked, onChange: (e) => update(q.id, e.target.value), children: [_jsx("option", { value: "", disabled: true, children: q.placeholder ?? t('Choose one') }), q.options.map((opt) => (_jsx("option", { value: opt.value, title: opt.description, children: opt.label }, opt.value)))] }), singleCustomChoice(ctx)] }));
168
+ }
169
+ function DirectionCardsControl(ctx) {
170
+ const { q, value, formId, locked, update } = ctx;
171
+ if (!q.cards || q.cards.length === 0)
172
+ return null;
173
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "qf-direction-cards", children: q.cards.map((card) => (_jsx(DirectionCardView, { card: card, formId: formId, questionId: q.id, selected: value === card.id || value === card.label, disabled: locked, onSelect: () => update(q.id, card.id) }, card.id))) }), singleCustomChoice(ctx)] }));
174
+ }
175
+ /** date/time/datetime-local and url/email/tel share one plain input whose `type` is the question type. */
176
+ function TypedInputControl({ q, value, locked, update }) {
177
+ return _jsx("input", { type: q.type, className: "qf-input", value: stringValue(value), placeholder: q.placeholder, disabled: locked, onChange: (e) => update(q.id, e.target.value) });
178
+ }
179
+ const QUESTION_CONTROLS = {
180
+ radio: RadioControl,
181
+ checkbox: CheckboxControl,
182
+ select: SelectControl,
183
+ 'direction-cards': DirectionCardsControl,
184
+ text: ({ q, value, locked, update }) => _jsx("input", { ...questionTextInputProps({ question: q }), className: "qf-input", value: stringValue(value), placeholder: q.placeholder, disabled: locked, onChange: (e) => update(q.id, e.target.value) }),
185
+ number: ({ q, value, locked, update }) => _jsx("input", { type: "number", className: "qf-input", value: stringValue(value), placeholder: q.placeholder, min: q.min, max: q.max, step: q.step, disabled: locked, onChange: (e) => update(q.id, e.target.value) }),
186
+ range: ({ q, value, locked, update }) => (_jsxs("div", { className: "qf-range-wrap", children: [_jsx("input", { type: "range", className: "qf-range", value: rangeValue(q, value), min: q.min, max: q.max, step: q.step, disabled: locked, onChange: (e) => update(q.id, e.target.value) }), _jsx("output", { className: "qf-range-value", children: rangeValue(q, value) })] })),
187
+ date: TypedInputControl,
188
+ time: TypedInputControl,
189
+ 'datetime-local': TypedInputControl,
190
+ url: TypedInputControl,
191
+ email: TypedInputControl,
192
+ tel: TypedInputControl,
193
+ color: ({ q, value, locked, update }) => _jsx("input", { type: "color", className: "qf-color", value: normalizeColorInputValue(value), disabled: locked, onChange: (e) => update(q.id, e.target.value) }),
194
+ file: ({ q, value, locked, selectFiles }) => (_jsxs("div", { className: "qf-file-wrap", children: [_jsx("input", { type: "file", className: "qf-file", multiple: q.multiple, accept: q.accept, disabled: locked, onChange: (e) => selectFiles(q, e.target.files) }), fileValueLabel(value) ? _jsx("div", { className: "qf-file-summary", children: fileValueLabel(value) }) : null] })),
195
+ switch: ({ q, value, locked, update }) => (_jsxs("label", { className: "qf-switch", children: [_jsx("input", { type: "checkbox", role: "switch", checked: value === 'true', disabled: locked, onChange: (e) => update(q.id, e.target.checked ? 'true' : 'false') }), _jsx("span", { "aria-hidden": true })] })),
196
+ textarea: ({ q, value, locked, update }) => _jsx("textarea", { ...questionTextareaProps({ question: q }), className: "qf-textarea", value: stringValue(value), placeholder: q.placeholder, disabled: locked, rows: 3, onChange: (e) => update(q.id, e.target.value) }),
197
+ };
135
198
  function OptionCopy({ option }) {
136
199
  return (_jsxs("span", { className: "qf-chip-copy", children: [_jsx("span", { children: option.label }), option.description ? _jsx("span", { className: "qf-chip-desc", children: option.description }) : null] }));
137
200
  }
@@ -145,22 +208,31 @@ function DirectionCardView({ card, formId, questionId, selected, disabled, onSel
145
208
  }
146
209
  function buildInitialState(form, submitted, draft) {
147
210
  const out = {};
211
+ for (const q of form.questions)
212
+ out[q.id] = initialQuestionValue(q, submitted, draft);
213
+ return out;
214
+ }
215
+ /** Submitted beats draft beats default beats empty; file drafts are never restored (the File objects are gone). */
216
+ function initialQuestionValue(q, submitted, draft) {
217
+ if (submitted?.[q.id] !== undefined)
218
+ return canonicalizeQuestionValue(q, submitted[q.id]);
219
+ if (draft?.[q.id] !== undefined && q.type !== 'file')
220
+ return canonicalizeQuestionValue(q, draft[q.id]);
221
+ if (q.defaultValue !== undefined)
222
+ return canonicalizeQuestionValue(q, q.defaultValue);
223
+ return emptyQuestionValue(q);
224
+ }
225
+ /** Questions that stream in after mount get their initial value; answers already set are kept. */
226
+ function backfillAnswers(prev, form, submitted) {
227
+ let changed = false;
228
+ const next = { ...prev };
148
229
  for (const q of form.questions) {
149
- if (submitted && submitted[q.id] !== undefined) {
150
- out[q.id] = canonicalizeQuestionValue(q, submitted[q.id]);
151
- continue;
152
- }
153
- if (draft && draft[q.id] !== undefined && q.type !== 'file') {
154
- out[q.id] = canonicalizeQuestionValue(q, draft[q.id]);
230
+ if (next[q.id] !== undefined)
155
231
  continue;
156
- }
157
- if (q.defaultValue !== undefined) {
158
- out[q.id] = canonicalizeQuestionValue(q, q.defaultValue);
159
- continue;
160
- }
161
- out[q.id] = emptyQuestionValue(q);
232
+ changed = true;
233
+ next[q.id] = initialQuestionValue(q, submitted, undefined);
162
234
  }
163
- return out;
235
+ return changed ? next : prev;
164
236
  }
165
237
  function draftSafeAnswers(form, answers) {
166
238
  const fileQuestionIds = new Set(form.questions.filter((q) => q.type === 'file').map((q) => q.id));
@@ -20,7 +20,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
20
  import { useRef, useState } from 'react';
21
21
  import { useT } from '../hooks/context.js';
22
22
  import { QuestionForm } from './QuestionForm.js';
23
- export function QuestionsPanel({ form, interactive, submitDisabled = false, submittedAnswers, generating = false, onSubmit, redactUserText, onSecretRedacted }) {
23
+ export function QuestionsPanel({ form, interactive, submitDisabled, submittedAnswers, generating = false, onSubmit, redactUserText, onSecretRedacted }) {
24
24
  const t = useT();
25
25
  const formRef = useRef(null);
26
26
  const [ready, setReady] = useState(false);