@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
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { AgentPluginUsage } from './AgentPluginUsage.js';
2
3
  import { SECRET_REDACTED_NOTICE } from '../../../../core/user-text-redaction.js';
3
- import { useEffect, useMemo, useRef } from 'react';
4
+ import { useEffect, useId, useMemo, useRef } from 'react';
4
5
  import { WorkingDirPicker } from '@jini-ai/ui';
5
6
  import { Composer } from '../../../components/Composer.js';
6
7
  import { appendComposerDiscovery } from '../../../components/composer-discovery.js';
7
8
  import { MessageList } from '../../../components/MessageList.js';
8
9
  import { useT } from '../../../hooks/context.js';
9
10
  import { definedProps } from '../../../util/defined-props.js';
10
- import { describeTypedAnswerNotice, isChatPaneApiModeConfigured, isChatPaneFileDropEnabled } from '../rules.js';
11
+ import { describeChatPaneSendBlocker, describeTypedAnswerNotice, isChatPaneApiModeConfigured, isChatPaneFileDropEnabled } from '../rules.js';
11
12
  import { useChatPane } from '../hooks/useChatPane.hooks.js';
12
13
  import { useChatPaneAgentControl } from '../hooks/useChatPaneAgentControl.hooks.js';
13
14
  import { useChatPaneComposerPlaceholder } from '../hooks/useChatPaneComposerPlaceholder.hooks.js';
@@ -75,10 +76,54 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
75
76
  }
76
77
  return (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t('No usable CLI is selected.'), ' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: () => onExecutionModeChange('api'), children: t('Switch to BYOK') })] }));
77
78
  }
79
+ /**
80
+ * `unavailable` alone conflates "inventory still loading" with "inventory loaded, nothing
81
+ * usable" — both present as `selectedAgent === undefined`. `scanningAgents` is the signal
82
+ * that tells them apart, mirroring the `workingDirectoryPending`/`workingDirectoryInvalid`
83
+ * pair below: a pending detection is a `status`, only a *finished* detection that found
84
+ * nothing is an `alert`. `connectingAgents` is a third state: the inventory could not be
85
+ * read at all (the host is unreachable, typically a daemon restart) and a retry is
86
+ * pending — a plain status too, never "No usable CLI", which only a SUCCESSFUL answer can
87
+ * establish.
88
+ */
89
+ function ChatPaneAvailabilityStatus({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, t, }) {
90
+ if (!unavailable)
91
+ return null;
92
+ if (connectingAgents) {
93
+ return (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') }));
94
+ }
95
+ if (scanningAgents) {
96
+ return (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') }));
97
+ }
98
+ return (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t }));
99
+ }
100
+ function ChatPaneWorkingDirectoryStatus({ workingDirectoryPending, workingDirectoryInvalid, t, }) {
101
+ if (workingDirectoryPending) {
102
+ return (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') }));
103
+ }
104
+ if (!workingDirectoryInvalid)
105
+ return null;
106
+ return (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') }));
107
+ }
108
+ /**
109
+ * The typed-answer delivery notice. Only `'failed'` ever reaches it: `useChatPane` queues a
110
+ * `'not-pending'` refusal as one ordinary message itself (`answerWaitingQuestion`), without a notice.
111
+ *
112
+ * MOVE (from the retired "Send as a new message" button this notice once rendered for
113
+ * `'not-pending'`): the held answer's only way into a new run besides clearing the draft — an
114
+ * explicit choice, so a closed question never turns an answer into a paid run on a second Enter.
115
+ * The hook's automatic one-time fallback now owns that guarantee, which left the button unreachable.
116
+ */
117
+ function ChatPaneTypedAnswerNotice({ typedAnswerNotice, t, }) {
118
+ if (!typedAnswerNotice)
119
+ return null;
120
+ return (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t(describeTypedAnswerNotice({ notice: typedAnswerNotice })) }));
121
+ }
78
122
  /** The pane's stack of mutually-independent error/status banners — each condition is its own
79
123
  * source of truth, so they are rendered as siblings rather than folded into one derived state. */
80
- function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, t, }) {
81
- return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
124
+ function ChatPaneStatusMessages({ attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, ...availability }) {
125
+ const { t } = availability;
126
+ return (_jsxs(_Fragment, { children: [_jsx(ChatPaneAvailabilityStatus, { ...availability }), attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, _jsx(ChatPaneWorkingDirectoryStatus, { workingDirectoryPending: workingDirectoryPending, workingDirectoryInvalid: workingDirectoryInvalid, t: t }), runtimeInventoryError && !availability.connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, _jsx(ChatPaneTypedAnswerNotice, { typedAnswerNotice: typedAnswerNotice, t: t })] }));
82
127
  }
83
128
  function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
84
129
  if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
@@ -166,63 +211,53 @@ function resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirector
166
211
  *
167
212
  * The pending-turn strip previously lived here (a banner bolted above the composer). It now
168
213
  * renders inside `<MessageList>` itself, as the newest transcript entry — see that component's
169
- * `pendingPrompt` prop and this component's own call site below. */
214
+ * `pendingPrompts` prop and this component's own call site below. */
170
215
  function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, unavailable, placeholder, slots, attachmentAccept, workingDirectoryAccess, workingDirectoryControlPlacement, t, }) {
216
+ const sendDisabledReasonId = useId();
217
+ const sendDisabledReason = pane.sendBlocker && pane.sendBlocker !== 'streaming'
218
+ ? t(describeChatPaneSendBlocker(pane.sendBlocker))
219
+ : undefined;
171
220
  return (_jsxs("div", { className: `jini-chat-pane__drop-target${fileDrop.draggingFiles ? ' is-dragging-files' : ''}`, "data-testid": "chat-pane-file-drop-target", "data-dragging-files": fileDrop.draggingFiles ? 'true' : 'false', ...resolveDropTargetProps(uploadAttachments, fileDrop), children: [fileDrop.draggingFiles ? (_jsx("span", { className: "jini-chat-pane__drop-announcement", role: "status", children: t('Drop files to attach') })) : null, _jsx(Composer, { composer: pane.composer, onSend: () => void pane.send(),
172
221
  // Streaming no longer locks the textarea: `pane.send()` queues a turn typed during a run
173
222
  // rather than refusing it, so the operator can keep writing while the agent thinks.
174
223
  disabled: disabled || unavailable,
175
224
  // The one blocker `send()` handles itself. Every other refusal still greys out the button.
176
- sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel, onInterrupt: pane.interruptSend, ...definedProps({ source: { placeholder } }), slots: slots, ...resolveComposerAttachmentPicker(uploadAttachments, pane, attachmentAccept), ...resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirectoryControlPlacement, pane) }), _jsx(ChatPaneWorkingDirectoryBlock, { workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, pane: pane, t: t })] }));
225
+ sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', ...definedProps({ source: { sendDisabledReason, sendDisabledReasonId: sendDisabledReason ? sendDisabledReasonId : undefined } }), running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel, onInterrupt: pane.interruptSend, ...definedProps({ source: { placeholder } }), slots: { ...slots, footerAccessories: _jsxs(_Fragment, { children: [sendDisabledReason ? _jsx("small", { id: sendDisabledReasonId, className: "jini-composer-send-blocker", children: sendDisabledReason }) : null, slots.footerAccessories] }) }, ...resolveComposerAttachmentPicker(uploadAttachments, pane, attachmentAccept), ...resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirectoryControlPlacement, pane) }), _jsx(ChatPaneWorkingDirectoryBlock, { workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, pane: pane, t: t })] }));
177
226
  }
178
- /**
179
- * Renders the self-contained chat-pane composition, including runtime and
180
- * working-directory orchestration owned by `@jini-ai/chat-react`.
181
- *
182
- * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
183
- * @overallScore 100/100
184
- */
185
- export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, refreshSignal, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, redactUserText, onSecretRedacted, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, deliverMidRunMessage, composerHandle, placeholder, placeholders, suggestions = [], emptyState, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, toolCallDisplay = 'show', showUsageLine = true, showThinking = false, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
186
- const t = useT();
187
- const surface = useChatPaneSurface({ variant, composerSlots, workingDirectoryControlPlacement, uploadAttachments });
188
- const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: placeholder, placeholders: placeholders });
189
- const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
190
- access: runtimeAccess,
191
- initialAgents: injectedAgents,
192
- pollIntervalMs: runtimeStatusPollMs,
193
- } }));
194
- const runtimeView = resolveRuntimeAccessView(runtimeAccess, injectedAgents, scanningAgents, daemonOnline, onRescanAgents, inventory);
195
- // Computed once and threaded into both `useChatPane` (so `sendBlocker` agrees) and this
196
- // component's own `unavailable` below (so the status banner and composer agree) — see
197
- // `isChatPaneApiModeConfigured` for why API mode alone isn't enough.
198
- const apiModeConfigured = isChatPaneApiModeConfigured({ executionMode, apiModeAvailable, byokRuntime });
199
- const pane = useChatPane(definedProps({ source: {
200
- transport,
201
- agents: runtimeView.agents,
202
- initialMessages,
203
- refreshSignal,
204
- conversationId,
205
- initialSelection,
206
- selection: controlledSelection,
207
- onSelectionChange,
208
- runContext,
209
- initialDraft,
210
- composerHistoryScope,
211
- composerHistoryStorage,
212
- validateAttachments,
213
- uploadAttachments,
214
- onActivityChange,
215
- onMessagesChange,
216
- redactUserText,
217
- onSecretRedacted,
218
- workingDirectory,
219
- initialWorkingDirectory,
220
- onChangeWorkingDirectory,
221
- workingDirectoryAccess,
222
- apiModeConfigured,
223
- deliverTypedAnswer,
224
- deliverMidRunMessage,
225
- } }));
227
+ const EMPTY_SUGGESTIONS = [];
228
+ const CHAT_PANE_DEFAULTS = {
229
+ agents: EMPTY_AGENTS,
230
+ runtimeStatusPollMs: 5_000,
231
+ variant: 'workspace',
232
+ scanningAgents: false,
233
+ daemonOnline: true,
234
+ executionMode: 'local',
235
+ apiModeAvailable: false,
236
+ suggestions: EMPTY_SUGGESTIONS,
237
+ workingDirectoryControlPlacement: 'below',
238
+ toolCallDisplay: 'show',
239
+ showUsageLine: true,
240
+ showThinking: false,
241
+ agentPluginsMode: 'auto',
242
+ disabled: false,
243
+ runtimePickerPlacement: 'up',
244
+ };
245
+ function applyChatPaneDefaults(props) {
246
+ const resolved = { ...props, ...CHAT_PANE_DEFAULTS };
247
+ for (const key of Object.keys(CHAT_PANE_DEFAULTS)) {
248
+ if (props[key] !== undefined)
249
+ Object.assign(resolved, { [key]: props[key] });
250
+ }
251
+ return resolved;
252
+ }
253
+ function resolveAgentControlOptions(agentControl) {
254
+ return definedProps({ source: {
255
+ enabled: agentControl?.enabled ?? false,
256
+ bridgeAccess: agentControl?.bridgeAccess,
257
+ webmcp: agentControl?.webmcp,
258
+ } });
259
+ }
260
+ function useComposerHandlePublisher({ pane }, { composerHandle }) {
226
261
  // Mirrors `Composer.tsx`'s own `draftRef`: `composerHandle.insertText` (below) is called from
227
262
  // OUTSIDE any render, so it cannot close over `pane.composer.draft` directly — that would freeze
228
263
  // it at whatever the draft was on the render that captured it. Reassigned unconditionally every
@@ -245,14 +280,100 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
245
280
  // closure on every render rather than genuinely skipping renders — cheap, and it keeps
246
281
  // `pane.composer.setDraft` from ever going stale if that reference ever changes.
247
282
  }, [composerHandle, pane.composer]);
283
+ }
284
+ function useChatPaneTranscriptProps({ pane }, { projectFileNames }) {
285
+ // `MessageList` is memoized so a composer keystroke does not re-render the transcript; these two
286
+ // props would otherwise be fresh objects on every render and defeat it.
287
+ const { queuedMessages, cancelQueuedMessage } = pane;
288
+ const pendingPrompts = useMemo(() => queuedMessages.map(input => ({ ...input, onCancel: () => cancelQueuedMessage({ id: input.id }) })), [queuedMessages, cancelQueuedMessage]);
289
+ const projectFileNameSet = useMemo(() => (projectFileNames === undefined ? undefined : new Set(projectFileNames)), [projectFileNames]);
290
+ return { pendingPrompts, projectFileNameSet };
291
+ }
292
+ /** `composerSlots`'s type excludes both `leadingAccessories` and `footerAccessories` (see
293
+ * `ChatPaneProps.composerSlots`'s doc), so the two explicit keys below are never overwriting a
294
+ * caller-supplied value — they are the ONLY source for both. */
295
+ function buildChatPaneSlots({ surfaceSlots, embed, renderRuntimePicker }, { leadingAccessory }) {
296
+ return definedProps({ source: {
297
+ ...surfaceSlots,
298
+ leadingAccessories: embed ? undefined : leadingAccessory,
299
+ footerAccessories: embed ? undefined : renderRuntimePicker(),
300
+ } });
301
+ }
302
+ function ChatPaneTranscript({ pane, emptyState, embed, suggestions, onSelectSuggestion, pendingPrompts, projectFileNameSet, toolCallDisplay, showUsageLine, showThinking, t, }) {
303
+ if (pane.conversation.messages.length === 0 && emptyState) {
304
+ return (_jsxs("div", { className: "jini-message-list jini-message-list--empty", children: [emptyState, embed && _jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: onSelectSuggestion, t: t })] }));
305
+ }
306
+ return (_jsx(MessageList, { messages: pane.conversation.messages, isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompts: pendingPrompts, toolCallDisplay: toolCallDisplay, showUsageLine: showUsageLine, showThinking: showThinking, ...(projectFileNameSet === undefined ? {} : { projectFileNames: projectFileNameSet }) }));
307
+ }
308
+ /** The controls-area suggestions row shows on every workspace surface, and on the embed surface
309
+ * only when the empty transcript has no `emptyState` to host it instead. */
310
+ function showsControlsSuggestions(embed, messageCount, emptyState) {
311
+ return !embed || (messageCount === 0 && !emptyState);
312
+ }
313
+ /**
314
+ * Renders the self-contained chat-pane composition, including runtime and
315
+ * working-directory orchestration owned by `@jini-ai/chat-react`.
316
+ *
317
+ * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
318
+ * @overallScore 100/100
319
+ */
320
+ export function ChatPane(props) {
321
+ const p = applyChatPaneDefaults(props);
322
+ const t = useT();
323
+ const surface = useChatPaneSurface({
324
+ variant: p.variant,
325
+ composerSlots: p.composerSlots,
326
+ workingDirectoryControlPlacement: p.workingDirectoryControlPlacement,
327
+ uploadAttachments: p.uploadAttachments,
328
+ });
329
+ const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: p.placeholder, placeholders: p.placeholders });
330
+ const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
331
+ access: p.runtimeAccess,
332
+ initialAgents: p.agents,
333
+ pollIntervalMs: p.runtimeStatusPollMs,
334
+ } }));
335
+ const runtimeView = resolveRuntimeAccessView(p.runtimeAccess, p.agents, p.scanningAgents, p.daemonOnline, p.onRescanAgents, inventory);
336
+ // Computed once and threaded into both `useChatPane` (so `sendBlocker` agrees) and this
337
+ // component's own `unavailable` below (so the status banner and composer agree) — see
338
+ // `isChatPaneApiModeConfigured` for why API mode alone isn't enough.
339
+ const apiModeConfigured = isChatPaneApiModeConfigured({
340
+ executionMode: p.executionMode,
341
+ apiModeAvailable: p.apiModeAvailable,
342
+ byokRuntime: p.byokRuntime,
343
+ });
344
+ const pane = useChatPane(definedProps({ source: {
345
+ transport: p.transport,
346
+ agents: runtimeView.agents,
347
+ initialMessages: p.initialMessages,
348
+ refreshSignal: p.refreshSignal,
349
+ conversationId: p.conversationId,
350
+ initialSelection: p.initialSelection,
351
+ selection: p.selection,
352
+ onSelectionChange: p.onSelectionChange,
353
+ runContext: p.runContext,
354
+ initialDraft: p.initialDraft,
355
+ composerHistoryScope: p.composerHistoryScope,
356
+ composerHistoryStorage: p.composerHistoryStorage,
357
+ composerHistoryCap: p.composerHistoryCap,
358
+ validateAttachments: p.validateAttachments,
359
+ uploadAttachments: p.uploadAttachments,
360
+ onActivityChange: p.onActivityChange,
361
+ onMessagesChange: p.onMessagesChange,
362
+ redactUserText: p.redactUserText,
363
+ onSecretRedacted: p.onSecretRedacted,
364
+ workingDirectory: p.workingDirectory,
365
+ initialWorkingDirectory: p.initialWorkingDirectory,
366
+ onChangeWorkingDirectory: p.onChangeWorkingDirectory,
367
+ workingDirectoryAccess: p.workingDirectoryAccess,
368
+ apiModeConfigured,
369
+ deliverTypedAnswer: p.deliverTypedAnswer,
370
+ deliverMidRunMessage: p.deliverMidRunMessage,
371
+ } }));
372
+ useComposerHandlePublisher({ pane }, definedProps({ source: { composerHandle: p.composerHandle } }));
248
373
  // No `runContext` here on purpose: agent-driven sends go through `pane.sendPrompt`, which builds
249
374
  // the context from the SAME `runContext` already handed to `useChatPane` above. A second copy
250
375
  // would be a second source of truth that could silently drift from the composer's.
251
- useChatPaneAgentControl({ pane: pane }, definedProps({ source: {
252
- enabled: agentControl?.enabled ?? false,
253
- bridgeAccess: agentControl?.bridgeAccess,
254
- webmcp: agentControl?.webmcp,
255
- } }));
376
+ useChatPaneAgentControl({ pane: pane }, resolveAgentControlOptions(p.agentControl));
256
377
  // Selection resolution only returns available agents, so absence normally means nothing usable
257
378
  // is selected — except a configured BYOK turn (`apiModeConfigured`, computed above), which calls
258
379
  // the provider directly over HTTP and never touches the CLI inventory.
@@ -260,28 +381,25 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
260
381
  const fileDrop = useChatPaneFileDrop({
261
382
  enabled: isChatPaneFileDropEnabled({
262
383
  canUpload: surface.uploadAttachments !== undefined,
263
- disabled,
384
+ disabled: p.disabled,
264
385
  unavailable,
265
386
  uploading: pane.isUploadingAttachments,
266
387
  }),
267
388
  onFiles: pane.addAttachments,
268
389
  });
269
- // `composerSlots`'s type excludes both `leadingAccessories` and `footerAccessories` (see
270
- // `ChatPaneProps.composerSlots`'s doc), so the two explicit keys below are never overwriting a
271
- // caller-supplied value — they are the ONLY source for both.
272
- const slots = definedProps({ source: {
273
- ...surface.composerSlots,
274
- leadingAccessories: surface.embed ? undefined : leadingAccessory,
275
- footerAccessories: surface.embed ? undefined : (_jsx(AgentRuntimePicker, { agents: runtimeView.agents, value: pane.selection, onChange: pane.setSelection, ...definedProps({ source: { onRescan: runtimeView.rescanAgents } }), scanning: runtimeView.scanningAgents, daemonOnline: runtimeView.daemonOnline, placement: runtimePickerPlacement, executionMode: executionMode, apiModeAvailable: apiModeAvailable, ...definedProps({ source: { onExecutionModeChange, byokRuntime, onByokModelChange } }) })),
276
- } });
390
+ const slots = buildChatPaneSlots({
391
+ surfaceSlots: surface.composerSlots,
392
+ embed: surface.embed,
393
+ renderRuntimePicker: () => (_jsx(AgentRuntimePicker, { agents: runtimeView.agents, value: pane.selection, onChange: pane.setSelection, ...definedProps({ source: { onRescan: runtimeView.rescanAgents } }), scanning: runtimeView.scanningAgents, daemonOnline: runtimeView.daemonOnline, placement: p.runtimePickerPlacement, executionMode: p.executionMode, apiModeAvailable: p.apiModeAvailable, ...definedProps({ source: {
394
+ onExecutionModeChange: p.onExecutionModeChange,
395
+ byokRuntime: p.byokRuntime,
396
+ onByokModelChange: p.onByokModelChange,
397
+ } }) })),
398
+ }, { leadingAccessory: p.leadingAccessory });
277
399
  const { rootRef, controlsRef } = useChatPaneControlsHeight();
278
400
  const selectSuggestion = useChatPaneSuggestion({ rootRef, setDraft: pane.composer.setDraft });
279
401
  const openLinkInNewTab = useChatPaneNewTabLinks();
280
- // `MessageList` is memoized so a composer keystroke does not re-render the transcript; these two
281
- // props would otherwise be fresh objects on every render and defeat it.
282
- const { queuedPrompt, cancelQueued } = pane;
283
- const pendingPrompt = useMemo(() => (queuedPrompt === null ? null : { text: queuedPrompt, onCancel: cancelQueued }), [queuedPrompt, cancelQueued]);
284
- const projectFileNameSet = useMemo(() => (projectFileNames === undefined ? undefined : new Set(projectFileNames)), [projectFileNames]);
285
- return (_jsxs("section", { className: chatPaneClassName(variant, className), style: style, "data-activity": pane.activity, ref: rootRef, onClickCapture: openLinkInNewTab, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), resolveChatPaneHeader(header, title, pane.reset, t), _jsxs("div", { className: "jini-chat-pane__body", children: [pane.conversation.messages.length === 0 && emptyState ? (_jsxs("div", { className: "jini-message-list jini-message-list--empty", children: [emptyState, surface.embed && _jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: selectSuggestion, t: t })] })) : (_jsx(MessageList, { messages: pane.conversation.messages, isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompt: pendingPrompt, toolCallDisplay: toolCallDisplay, showUsageLine: showUsageLine, showThinking: showThinking, ...(projectFileNameSet === undefined ? {} : { projectFileNames: projectFileNameSet }) })), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [(!surface.embed || (pane.conversation.messages.length === 0 && !emptyState)) && (_jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: selectSuggestion, t: t })), pane.secretRedacted ? _jsx("div", { className: "jini-chat-pane__notice", role: "status", children: t(SECRET_REDACTED_NOTICE) }) : null, _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, connectingAgents: runtimeView.connectingAgents, executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, conversationError: pane.conversation.error, attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, typedAnswerNotice: pane.typedAnswerNotice, onSendAsNewMessage: () => void pane.sendAsNewMessage(), t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: surface.uploadAttachments, pane: pane, disabled: disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: attachmentAccept, workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: surface.workingDirectoryControlPlacement, t: t })] })] }), footer] }));
402
+ const { pendingPrompts, projectFileNameSet } = useChatPaneTranscriptProps({ pane }, definedProps({ source: { projectFileNames: p.projectFileNames } }));
403
+ return (_jsxs("section", { className: chatPaneClassName(p.variant, p.className), style: p.style, "data-activity": pane.activity, ref: rootRef, onClickCapture: openLinkInNewTab, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), resolveChatPaneHeader(p.header, p.title, pane.reset, t), p.agentPluginsMode !== 'off' && _jsx(AgentPluginUsage, { conversationId: pane.conversation.conversationId, messages: pane.conversation.messages }), _jsxs("div", { className: "jini-chat-pane__body", children: [_jsx(ChatPaneTranscript, { pane: pane, emptyState: p.emptyState, embed: surface.embed, suggestions: p.suggestions, onSelectSuggestion: selectSuggestion, pendingPrompts: pendingPrompts, projectFileNameSet: projectFileNameSet, toolCallDisplay: p.toolCallDisplay, showUsageLine: p.showUsageLine, showThinking: p.showThinking, t: t }), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [showsControlsSuggestions(surface.embed, pane.conversation.messages.length, p.emptyState) && (_jsx(ChatPaneSuggestionsRow, { suggestions: p.suggestions, onSelect: selectSuggestion, t: t })), pane.secretRedacted ? _jsx("div", { className: "jini-chat-pane__notice", role: "status", children: t(SECRET_REDACTED_NOTICE) }) : null, _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, connectingAgents: runtimeView.connectingAgents, executionMode: p.executionMode, apiModeAvailable: p.apiModeAvailable, onExecutionModeChange: p.onExecutionModeChange, attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, typedAnswerNotice: pane.typedAnswerNotice, t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: surface.uploadAttachments, pane: pane, disabled: p.disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: p.attachmentAccept, workingDirectoryAccess: p.workingDirectoryAccess, workingDirectoryControlPlacement: surface.workingDirectoryControlPlacement, t: t })] })] }), p.footer] }));
286
404
  }
287
405
  //# sourceMappingURL=ChatPane.js.map
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface RuntimeDropUpProps {
3
+ /** Names both the trigger (with the current value appended) and the menu. */
4
+ label: string;
5
+ /** What the trigger shows: the current choice's display label. */
6
+ valueLabel: string;
7
+ /** A shorter form the trigger shows instead (e.g. "Opus 5.5"); the full `valueLabel` then
8
+ * appears in a hover/focus tooltip and stays the trigger's accessible name. */
9
+ triggerLabel?: string | undefined;
10
+ value: string;
11
+ options: readonly {
12
+ id: string;
13
+ label: string;
14
+ }[];
15
+ onSelect: (id: string) => void;
16
+ /** Small muted line above the options (e.g. the model catalog's "Cached · …" note). */
17
+ note?: string | undefined;
18
+ /** Menu width cap in px; the menu is also clamped to the viewport. */
19
+ width: number;
20
+ className?: string;
21
+ }
22
+ /**
23
+ * A compact, borderless "Label ⌄" trigger in the composer bar that opens a menu of choices above
24
+ * itself. Used for the model and the reasoning effort; the selection itself stays with the caller.
25
+ *
26
+ * @complexity Time/space: O(n) in the option count.
27
+ */
28
+ export declare function RuntimeDropUp({ label, valueLabel, triggerLabel, value, options, onSelect, note, width, className, }: RuntimeDropUpProps): ReactNode;
29
+ //# sourceMappingURL=RuntimeDropUp.d.ts.map
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { createPortal } from 'react-dom';
3
+ import { RemixIcon, infoTipBubbleStyle, useInfoTip } from '@jini-ai/ui';
4
+ import { useRuntimeDropUp } from '../hooks/useRuntimeDropUp.hooks.js';
5
+ /** The full `valueLabel` in the same white bubble `InfoTip` uses, portalled to `document.body`. */
6
+ function RuntimeDropUpTipBubble({ tip, valueLabel }) {
7
+ return createPortal(_jsx("span", { ref: tip.bubbleRef, className: tip.placement === 'below' ? 'info-tip-bubble info-tip-bubble-below' : 'info-tip-bubble', role: "presentation", "aria-hidden": "true", style: infoTipBubbleStyle(tip.coords), children: valueLabel }), document.body);
8
+ }
9
+ function RuntimeDropUpMenu({ dropUp, label, value, options, onSelect, note }) {
10
+ if (!dropUp.open || !dropUp.position)
11
+ return null;
12
+ return (_jsxs("div", { ref: dropUp.menuRef, id: dropUp.menuId, role: "menu", "aria-label": label, className: "jini-runtime-dropup", style: dropUp.position, onKeyDown: dropUp.handleMenuKeyDown, children: [note ? _jsx("div", { className: "jini-runtime-dropup__note", children: note }) : null, options.map((option) => {
13
+ const active = option.id === value;
14
+ return (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": active, className: `jini-runtime-dropup__item${active ? ' is-active' : ''}`, onClick: () => dropUp.choose(() => onSelect(option.id)), children: [_jsx("span", { className: "jini-runtime-dropup__item-label", children: option.label }), active ? _jsx(RemixIcon, { name: "check-line", size: 16, className: "jini-runtime-check" }) : null] }, option.id));
15
+ })] }));
16
+ }
17
+ /**
18
+ * A compact, borderless "Label ⌄" trigger in the composer bar that opens a menu of choices above
19
+ * itself. Used for the model and the reasoning effort; the selection itself stays with the caller.
20
+ *
21
+ * @complexity Time/space: O(n) in the option count.
22
+ */
23
+ export function RuntimeDropUp({ label, valueLabel, triggerLabel, value, options, onSelect, note, width, className, }) {
24
+ const dropUp = useRuntimeDropUp({ width });
25
+ const { menuId, open, triggerRef, toggleOpen, handleTriggerKeyDown } = dropUp;
26
+ // The same white bubble as `InfoTip` (its hook places it), rather than the browser's own title
27
+ // tooltip; only when the trigger shows a shortened label, so there is something to reveal.
28
+ const tip = useInfoTip({}, { placement: 'top' });
29
+ const shortened = triggerLabel !== undefined && triggerLabel !== valueLabel;
30
+ // No tip over the open menu, which already lists the full name.
31
+ const tipVisible = shortened && !open;
32
+ const showTip = () => {
33
+ if (tipVisible)
34
+ tip.show();
35
+ };
36
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { ref: triggerRef, type: "button", className: `jini-runtime-dropup-trigger${className ? ` ${className}` : ''}`, "aria-label": `${label}: ${valueLabel}`, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": menuId, ...(shortened ? {} : { title: valueLabel }), onClick: () => {
37
+ tip.hide();
38
+ toggleOpen();
39
+ }, onKeyDown: (event) => {
40
+ if (event.key === 'Escape')
41
+ tip.hide();
42
+ handleTriggerKeyDown(event);
43
+ }, onMouseEnter: showTip, onMouseLeave: tip.leave, onFocus: showTip, onBlur: tip.hide, children: [_jsx("span", { ref: tip.iconRef, className: "jini-runtime-dropup-trigger__label", children: triggerLabel ?? valueLabel }), _jsx(RemixIcon, { name: "arrow-down-s-line", size: 15, className: "jini-runtime-trigger__chevron" })] }), tip.open && tipVisible ? _jsx(RuntimeDropUpTipBubble, { tip: tip, valueLabel: valueLabel }) : null, _jsx(RuntimeDropUpMenu, { dropUp: dropUp, label: label, value: value, options: options, onSelect: onSelect, note: note })] }));
44
+ }
45
+ //# sourceMappingURL=RuntimeDropUp.js.map
@@ -43,6 +43,66 @@ async function messageForFailedUpload(response, fallback) {
43
43
  return fallback;
44
44
  }
45
45
  }
46
+ /** Refuses a selection that would break a per-file, per-turn count, or per-turn byte quota. */
47
+ function assertWithinQuota({ files, current, incomingBytes, quota }) {
48
+ if (files.some((file) => file.size > quota.maxAttachmentBytes)) {
49
+ throw new Error(`Each attachment must be ${formatByteLimit(quota.maxAttachmentBytes)} or smaller.`);
50
+ }
51
+ if (current.count + files.length > quota.maxAttachmentCount) {
52
+ throw new Error(`You can attach at most ${quota.maxAttachmentCount} files to one message.`);
53
+ }
54
+ if (current.bytes + incomingBytes > quota.maxBatchBytes) {
55
+ throw new Error(`Attachments for one message must total ${formatByteLimit(quota.maxBatchBytes)} or less.`);
56
+ }
57
+ }
58
+ /** Forwards the composer's abort (including one that already happened) into `controller`; returns the unlink. */
59
+ function linkParentAbort({ controller, parent }) {
60
+ if (parent === undefined)
61
+ return () => { };
62
+ const abortFromParent = () => controller.abort(parent.reason);
63
+ parent.addEventListener('abort', abortFromParent, { once: true });
64
+ if (parent.aborted)
65
+ abortFromParent();
66
+ return () => parent.removeEventListener('abort', abortFromParent);
67
+ }
68
+ /**
69
+ * Runs `concurrency` workers over a shared index, writing each result at its input position in
70
+ * `uploaded`. Never rejects: the first failure is returned (and aborts the rest) instead.
71
+ */
72
+ async function uploadInOrder({ files, concurrency, controller, uploaded, uploadOne }) {
73
+ let nextIndex = 0;
74
+ let firstError;
75
+ const worker = async () => {
76
+ while (!controller.signal.aborted) {
77
+ const index = nextIndex;
78
+ nextIndex += 1;
79
+ const file = files[index];
80
+ if (file === undefined)
81
+ return;
82
+ try {
83
+ uploaded[index] = await uploadOne(file);
84
+ }
85
+ catch (error) {
86
+ // Kept, not thrown: aborting stops the sibling worker, and the *first* failure is the one
87
+ // worth reporting — the aborts it causes are noise.
88
+ firstError ??= error;
89
+ controller.abort();
90
+ }
91
+ }
92
+ };
93
+ await Promise.all(Array.from({ length: concurrency }, worker));
94
+ return firstError;
95
+ }
96
+ /** Throws the turn's first upload failure, else the abort that stopped it; returns when neither happened. */
97
+ function throwIfUploadFailed({ firstError, signal }) {
98
+ if (firstError !== undefined)
99
+ throw firstError;
100
+ if (signal.aborted) {
101
+ throw signal.reason instanceof Error
102
+ ? signal.reason
103
+ : new Error('Attachment upload was canceled.');
104
+ }
105
+ }
46
106
  /**
47
107
  * Builds a `ChatPaneProps['uploadAttachments']` bound to one daemon.
48
108
  *
@@ -50,17 +110,21 @@ async function messageForFailedUpload(response, fallback) {
50
110
  * when the page is served through a proxy that already forwards `/api`.
51
111
  */
52
112
  export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {}) {
53
- const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULTS.maxAttachmentBytes;
54
- const maxAttachmentCount = options.maxAttachmentCount ?? DEFAULTS.maxAttachmentCount;
55
- const maxBatchBytes = options.maxBatchBytes ?? DEFAULTS.maxBatchBytes;
113
+ const quota = {
114
+ maxAttachmentBytes: options.maxAttachmentBytes ?? DEFAULTS.maxAttachmentBytes,
115
+ maxAttachmentCount: options.maxAttachmentCount ?? DEFAULTS.maxAttachmentCount,
116
+ maxBatchBytes: options.maxBatchBytes ?? DEFAULTS.maxBatchBytes,
117
+ };
56
118
  const timeoutMs = options.timeoutMs ?? DEFAULTS.timeoutMs;
57
119
  const concurrency = options.concurrency ?? DEFAULTS.concurrency;
58
120
  const endpoint = `${baseUrl.replace(/\/$/u, '')}/api/attachments`;
121
+ // Read per request rather than captured once, so a global installed after construction is used.
122
+ const send = (input, init) => (fetch ?? globalThis.fetch)(input, init);
59
123
  // Per-uploader, not module-level: two panes pointed at two daemons must not share a quota.
60
124
  const batchUsage = new Map();
61
125
  async function uploadOne(file, batchId, signal) {
62
126
  const query = new URLSearchParams({ batch: batchId, name: file.name });
63
- const response = await (fetch ?? globalThis.fetch)(`${endpoint}?${query}`, {
127
+ const response = await send(`${endpoint}?${query}`, {
64
128
  method: 'POST',
65
129
  // Fixed, not `file.type` — see this module's doc.
66
130
  headers: { 'content-type': 'application/octet-stream' },
@@ -84,7 +148,7 @@ export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {})
84
148
  // caller-supplied signal to compose with and no large body — it is a small best-effort JSON
85
149
  // DELETE, and an unprotected `fetch` here would hang `uploadAttachments`'s rejection (the
86
150
  // `await` below) on a stalled daemon instead of surfacing `firstError` promptly.
87
- await (fetch ?? globalThis.fetch)(endpoint, {
151
+ await send(endpoint, {
88
152
  method: 'DELETE',
89
153
  signal: AbortSignal.timeout(15_000),
90
154
  headers: { 'content-type': 'application/json' },
@@ -104,15 +168,7 @@ export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {})
104
168
  const batchId = uploadOptions?.batchId ?? crypto.randomUUID();
105
169
  const incomingBytes = files.reduce((total, file) => total + file.size, 0);
106
170
  const current = batchUsage.get(batchId) ?? { count: 0, bytes: 0, touchedAt: now };
107
- if (files.some((file) => file.size > maxAttachmentBytes)) {
108
- throw new Error(`Each attachment must be ${formatByteLimit(maxAttachmentBytes)} or smaller.`);
109
- }
110
- if (current.count + files.length > maxAttachmentCount) {
111
- throw new Error(`You can attach at most ${maxAttachmentCount} files to one message.`);
112
- }
113
- if (current.bytes + incomingBytes > maxBatchBytes) {
114
- throw new Error(`Attachments for one message must total ${formatByteLimit(maxBatchBytes)} or less.`);
115
- }
171
+ assertWithinQuota({ files, current, incomingBytes, quota });
116
172
  // Reserved up front and rolled back on failure, so two overlapping calls for the same turn
117
173
  // cannot each see the same free headroom.
118
174
  batchUsage.set(batchId, {
@@ -121,32 +177,9 @@ export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {})
121
177
  touchedAt: now,
122
178
  });
123
179
  const controller = new AbortController();
124
- const abortFromParent = () => controller.abort(uploadOptions?.signal.reason);
125
- uploadOptions?.signal.addEventListener('abort', abortFromParent, { once: true });
126
- if (uploadOptions?.signal.aborted)
127
- abortFromParent();
180
+ const unlinkParentAbort = linkParentAbort({ controller, parent: uploadOptions?.signal });
128
181
  const timeout = globalThis.setTimeout(() => controller.abort(new DOMException('Attachment upload timed out', 'TimeoutError')), timeoutMs);
129
182
  const uploaded = new Array(files.length);
130
- let nextIndex = 0;
131
- let firstError;
132
- const worker = async () => {
133
- while (!controller.signal.aborted) {
134
- const index = nextIndex;
135
- nextIndex += 1;
136
- const file = files[index];
137
- if (file === undefined)
138
- return;
139
- try {
140
- uploaded[index] = await uploadOne(file, batchId, controller.signal);
141
- }
142
- catch (error) {
143
- // Kept, not thrown: aborting stops the sibling worker, and the *first* failure is the one
144
- // worth reporting — the aborts it causes are noise.
145
- firstError ??= error;
146
- controller.abort();
147
- }
148
- }
149
- };
150
183
  const settled = () => uploaded.filter((attachment) => attachment !== undefined);
151
184
  // The failure is captured rather than rethrown from inside the `try`, so that the timer/listener
152
185
  // teardown in `finally` has exactly two reachable paths (the turn succeeded, or it did not)
@@ -154,14 +187,14 @@ export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {})
154
187
  let failure;
155
188
  let succeeded = false;
156
189
  try {
157
- await Promise.all(Array.from({ length: concurrency }, worker));
158
- if (firstError !== undefined)
159
- throw firstError;
160
- if (controller.signal.aborted) {
161
- throw controller.signal.reason instanceof Error
162
- ? controller.signal.reason
163
- : new Error('Attachment upload was canceled.');
164
- }
190
+ const firstError = await uploadInOrder({
191
+ files,
192
+ concurrency,
193
+ controller,
194
+ uploaded,
195
+ uploadOne: (file) => uploadOne(file, batchId, controller.signal),
196
+ });
197
+ throwIfUploadFailed({ firstError, signal: controller.signal });
165
198
  succeeded = true;
166
199
  }
167
200
  catch (error) {
@@ -169,7 +202,7 @@ export function createDaemonAttachmentUploader({ baseUrl, fetch }, options = {})
169
202
  }
170
203
  finally {
171
204
  globalThis.clearTimeout(timeout);
172
- uploadOptions?.signal.removeEventListener('abort', abortFromParent);
205
+ unlinkParentAbort();
173
206
  }
174
207
  if (succeeded)
175
208
  return settled();