@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
@@ -65,7 +65,7 @@ function runComposerHostEffect(effectName, effect, onSettled, onResolved) {
65
65
  * @complexity Time/space: O(n) in rendered attachments and supplied menu items.
66
66
  * @overallScore 100/100
67
67
  */
68
- export function Composer({ composer, onSend, disabled = false, sendDisabled = false, placeholder, slots, attachmentPicker, running = false, onCancel, onInterrupt, workingDirectory, onChangeWorkingDirectory, onPickWorkingDirectory, }) {
68
+ export function Composer({ composer, onSend, disabled = false, sendDisabled = false, sendDisabledReason, sendDisabledReasonId, placeholder, slots, attachmentPicker, running = false, onCancel, onInterrupt, workingDirectory, onChangeWorkingDirectory, onPickWorkingDirectory, }) {
69
69
  const t = useT();
70
70
  const attachmentInputRef = useRef(null);
71
71
  const textareaRef = useRef(null);
@@ -88,24 +88,14 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
88
88
  const [workdirOpen, setWorkdirOpen] = useState(false);
89
89
  const [workdirDraft, setWorkdirDraft] = useState('');
90
90
  const [workdirPosition, setWorkdirPosition] = useState();
91
- const resolvedPlaceholder = placeholder ?? t('Send a message…');
92
- const discoveryGroups = slots?.discoveryGroups ?? [];
91
+ const { discoveryGroups = [], leadingAccessories, footerLeadingAccessory, footerAccessories, plusMenuItems } = slots ?? {};
93
92
  const hasDiscoveryItems = discoveryGroups.some((group) => group.items.length > 0);
94
- const slashQuery = parseComposerSlashQuery({ draft: composer.draft });
95
- const slashMatches = slashQuery === null ? [] : filterComposerDiscovery({ groups: discoveryGroups, query: slashQuery });
96
- const slashOpen = slashMatches.length > 0 && dismissedSlashDraft !== composer.draft;
97
- /**
98
- * A bare "/" intentionally matches every item (`matchesFuzzyCommand`'s own doc) so the palette
99
- * doubles as a browse-everything view — but with nothing typed yet, nothing on screen tells a
100
- * first-time user that continuing to type narrows it, and a fuzzy substring match against full
101
- * descriptions stays broad for the first character or two regardless (owner-reported "it doesn't
102
- * narrow" — narrowing DOES fire on every keystroke, this is the one state where there's no visual
103
- * cue that it will). Shown only here, not once the user has started typing a real query — at that
104
- * point the shrinking list is its own feedback.
105
- */
106
- const showSlashFilterHint = slashQuery !== null && slashQuery.command === '' && slashQuery.argument === null;
93
+ const slash = slashPalette({ draft: composer.draft, groups: discoveryGroups, dismissedSlashDraft });
94
+ const slashOpen = slash.open;
95
+ const slashActiveOption = Math.min(slashActiveIndex, slash.matches.length - 1);
96
+ const popovers = { discoveryMenuOpen, slashOpen, workdirOpen };
107
97
  const handleHistoryKey = useComposerHistoryKeys({ composer, textareaRef }, {
108
- popupOpen: slashOpen || discoveryMenuOpen || workdirOpen || composer.mention.open,
98
+ popupOpen: anyPopoverOpen(popovers) || composer.mention.open,
109
99
  });
110
100
  function restoreComposerFocus() {
111
101
  textareaRef.current?.focus();
@@ -123,31 +113,22 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
123
113
  * component doesn't otherwise need direct handles to.
124
114
  */
125
115
  useEffect(() => {
126
- if (!discoveryMenuOpen && !slashOpen && !workdirOpen)
116
+ if (!anyPopoverOpen(popovers))
127
117
  return;
128
- const composerRoot = textareaRef.current?.closest('.jini-composer');
118
+ const textarea = textareaRef.current;
119
+ const composerRoot = textarea?.closest('.jini-composer');
129
120
  if (!composerRoot)
130
121
  return;
131
122
  function handleOutsideMouseDown(event) {
132
123
  const target = event.target;
133
124
  if (!target)
134
125
  return;
135
- if (discoveryMenuOpen) {
136
- const trigger = composerRoot.querySelector('.jini-composer-discovery');
137
- if (!trigger?.contains(target))
138
- setDiscoveryMenuOpen(false);
139
- }
140
- if (slashOpen) {
141
- const menu = composerRoot.querySelector('#jini-composer-slash-menu');
142
- const insideTextarea = textareaRef.current?.contains(target) ?? false;
143
- if (!insideTextarea && !menu?.contains(target))
144
- setDismissedSlashDraft(composer.draft);
145
- }
146
- if (workdirOpen) {
147
- const trigger = composerRoot.querySelector('.jini-composer-workdir');
148
- if (!trigger?.contains(target))
149
- setWorkdirOpen(false);
150
- }
126
+ if (discoveryMenuOpen && isOutside(composerRoot, '.jini-composer-discovery', target))
127
+ setDiscoveryMenuOpen(false);
128
+ if (slashOpen && !textarea.contains(target) && isOutside(composerRoot, '#jini-composer-slash-menu', target))
129
+ setDismissedSlashDraft(composer.draft);
130
+ if (workdirOpen && isOutside(composerRoot, '.jini-composer-workdir', target))
131
+ setWorkdirOpen(false);
151
132
  }
152
133
  document.addEventListener('mousedown', handleOutsideMouseDown);
153
134
  return () => document.removeEventListener('mousedown', handleOutsideMouseDown);
@@ -163,7 +144,7 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
163
144
  * composer's on-screen position can change under an open popover without it ever unmounting.
164
145
  */
165
146
  useLayoutEffect(() => {
166
- if (!discoveryMenuOpen && !slashOpen && !workdirOpen)
147
+ if (!anyPopoverOpen(popovers))
167
148
  return undefined;
168
149
  const composerRoot = textareaRef.current?.closest('.jini-composer');
169
150
  if (!composerRoot)
@@ -222,11 +203,8 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
222
203
  composer.setDraft(draft);
223
204
  });
224
205
  }
225
- function selectSlashItem(index) {
226
- const match = slashMatches[index];
227
- if (!match)
228
- return;
229
- const resolution = resolveComposerSlashInvocation({ draft: composer.draft, item: match.item });
206
+ function selectSlashItem(item) {
207
+ const resolution = resolveComposerSlashInvocation({ draft: composer.draft, item });
230
208
  if (!resolution)
231
209
  return;
232
210
  if (resolution.type === 'complete') {
@@ -238,12 +216,12 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
238
216
  return;
239
217
  }
240
218
  let expectedDraft = composer.draft;
241
- if (!match.item.command) {
242
- expectedDraft = replaceComposerSlashTrigger({ draft: composer.draft, insertText: match.item.insertText ?? match.item.label });
219
+ if (!item.command) {
220
+ expectedDraft = replaceComposerSlashTrigger({ draft: composer.draft, insertText: item.insertText ?? item.label });
243
221
  composer.setDraft(expectedDraft);
244
222
  }
245
223
  setDismissedSlashDraft(null);
246
- notifyDiscovery(match.item, 'slash', expectedDraft, resolution.argument);
224
+ notifyDiscovery(item, 'slash', expectedDraft, resolution.argument);
247
225
  restoreComposerFocus();
248
226
  }
249
227
  function selectPlusItem(item) {
@@ -271,30 +249,26 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
271
249
  onChangeWorkingDirectory?.(trimmed);
272
250
  setWorkdirOpen(false);
273
251
  }
274
- function handleKeyDown(event) {
275
- if (event.nativeEvent.isComposing)
276
- return;
277
- if (slashOpen) {
278
- const action = resolveComposerSlashKeyAction({ key: event.key, shiftKey: event.shiftKey });
279
- if (action.type !== 'none') {
280
- event.preventDefault();
281
- if (action.type === 'move') {
282
- setSlashActiveIndex((current) => (current + action.offset + slashMatches.length) % slashMatches.length);
283
- }
284
- else if (action.type === 'select') {
285
- selectSlashItem(Math.min(slashActiveIndex, slashMatches.length - 1));
286
- }
287
- else {
288
- setDismissedSlashDraft(composer.draft);
289
- }
290
- return;
291
- }
292
- }
293
- if (handleHistoryKey(event))
294
- return;
295
- if (event.key !== 'Enter' || event.shiftKey)
296
- return;
252
+ /** Palette navigation wins over history and Enter while the slash palette is open; true when it consumed the key. */
253
+ function handleSlashKey(event) {
254
+ if (!slashOpen)
255
+ return false;
256
+ const action = resolveComposerSlashKeyAction({ key: event.key, shiftKey: event.shiftKey });
257
+ if (action.type === 'none')
258
+ return false;
297
259
  event.preventDefault();
260
+ if (action.type === 'move') {
261
+ setSlashActiveIndex((current) => (current + action.offset + slash.matches.length) % slash.matches.length);
262
+ }
263
+ else if (action.type === 'select') {
264
+ selectSlashItem(slash.matches[slashActiveOption].item);
265
+ }
266
+ else {
267
+ setDismissedSlashDraft(composer.draft);
268
+ }
269
+ return true;
270
+ }
271
+ function submitFromEnter(event) {
298
272
  if (disabled || !composer.canSubmit)
299
273
  return;
300
274
  // Cmd/Ctrl+Enter ends the current run and sends this next; plain Enter queues behind it.
@@ -307,6 +281,18 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
307
281
  if (!sendDisabled)
308
282
  onSend();
309
283
  }
284
+ function handleKeyDown(event) {
285
+ if (event.nativeEvent.isComposing)
286
+ return;
287
+ if (handleSlashKey(event))
288
+ return;
289
+ if (handleHistoryKey(event))
290
+ return;
291
+ if (event.key !== 'Enter' || event.shiftKey)
292
+ return;
293
+ event.preventDefault();
294
+ submitFromEnter(event);
295
+ }
310
296
  function handleAttachmentChange(event) {
311
297
  const files = Array.from(event.target.files ?? []);
312
298
  event.target.value = '';
@@ -334,26 +320,84 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
334
320
  event.preventDefault();
335
321
  runComposerHostEffect('attachmentPicker.onFiles', () => attachmentPicker.onFiles(files));
336
322
  }
337
- return (_jsxs("div", { className: "jini-composer", children: [slots?.leadingAccessories ? _jsx("div", { className: "jini-composer-leading", children: slots.leadingAccessories }) : null, _jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: resolvedPlaceholder, disabled: disabled, onChange: (e) => {
323
+ return (_jsxs("div", { className: "jini-composer", children: [_jsx(AccessorySlot, { className: "jini-composer-leading", children: leadingAccessories }), _jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: placeholder ?? t('Send a message…'), disabled: disabled, onChange: (e) => {
338
324
  composer.setDraft(e.target.value);
339
325
  setSlashActiveIndex(0);
340
326
  setDismissedSlashDraft(null);
341
327
  }, onKeyDown: handleKeyDown,
342
328
  // Draft persistence is debounced (see `DRAFT_PERSIST_DEBOUNCE_MS`); leaving the field is a
343
329
  // natural point to make it durable now.
344
- onBlur: () => composer.flushDraft?.(), onPaste: handleAttachmentPaste, "aria-controls": slashOpen ? 'jini-composer-slash-menu' : undefined, "aria-expanded": slashOpen, "aria-activedescendant": slashOpen
345
- ? `jini-composer-slash-option-${Math.min(slashActiveIndex, slashMatches.length - 1)}`
346
- : undefined, rows: 3 }), slashOpen ? (_jsx(ComposerSlashMenu, { matches: slashMatches, activeIndex: Math.min(slashActiveIndex, slashMatches.length - 1), onSelect: (item) => selectSlashItem(slashMatches.findIndex((match) => match.item === item)), showFilterHint: showSlashFilterHint, style: slashMenuPosition, t: t })) : null, _jsxs("div", { className: "jini-composer-footer", children: [attachmentPicker && !hasDiscoveryItems ? (_jsxs("div", { className: "jini-composer-attachment-picker", children: [_jsx("input", { ref: attachmentInputRef, className: "jini-composer-file-input", "data-testid": "composer-attachment-input", type: "file", multiple: true, "aria-label": t('Attach files'), accept: attachmentPicker.accept, disabled: disabled || attachmentPicker.uploading, onChange: handleAttachmentChange }), _jsx("button", { type: "button", className: "jini-composer-attach", disabled: disabled || attachmentPicker.uploading, onClick: () => attachmentInputRef.current?.click(), title: t(attachmentPicker.uploading ? 'Attaching files…' : 'Attach files'), "aria-label": t(attachmentPicker.uploading ? 'Attaching files…' : 'Attach files'), children: _jsx(RemixIcon, { name: attachmentPicker.uploading ? 'refresh-line' : 'add-line', size: 20, ...(attachmentPicker.uploading ? { className: 'jini-composer-spinner' } : {}) }) })] })) : null, hasDiscoveryItems ? (_jsx(ComposerDiscoveryMenu, { groups: discoveryGroups, open: discoveryMenuOpen, disabled: disabled, ...(attachmentPicker ? { attachmentPicker } : {}), attachmentInputRef: attachmentInputRef, onToggle: () => setDiscoveryMenuOpen((open) => !open), onClose: () => setDiscoveryMenuOpen(false), onSelect: selectPlusItem, onAttachmentChange: handleAttachmentChange, style: discoveryMenuPosition, t: t })) : null, slots?.footerLeadingAccessory ? (_jsx("div", { className: "jini-composer-footer-leading", children: slots.footerLeadingAccessory })) : null, onChangeWorkingDirectory || onPickWorkingDirectory ? (_jsxs("div", { className: "jini-composer-workdir", "data-testid": "composer-workdir", children: [_jsx("button", { type: "button", className: "jini-composer-attach", "data-testid": "composer-workdir-trigger", disabled: disabled, onClick: onPickWorkingDirectory ? () => onPickWorkingDirectory() : toggleWorkdir, ...(onPickWorkingDirectory ? {} : { 'aria-haspopup': 'dialog', 'aria-expanded': workdirOpen }), title: workingDirectory ?? t('Set working directory'), "aria-label": workingDirectory
347
- ? t('Working directory: {dir}', { dir: workingDirectory })
348
- : t('Set working directory'), children: _jsx(RemixIcon, { name: "folder-line", size: 20 }) }), !onPickWorkingDirectory && workdirOpen ? (_jsxs("div", { className: "jini-composer-workdir-panel", "data-testid": "composer-workdir-panel", role: "dialog", "aria-label": t('Working directory'), style: workdirPosition, children: [_jsx("input", { type: "text", className: "jini-composer-workdir-input", "data-testid": "composer-workdir-input", value: workdirDraft, onChange: (e) => setWorkdirDraft(e.target.value), onKeyDown: (e) => {
349
- if (e.key === 'Enter') {
350
- e.preventDefault();
351
- confirmWorkdir();
352
- }
353
- else if (e.key === 'Escape') {
354
- e.preventDefault();
355
- setWorkdirOpen(false);
356
- }
357
- }, placeholder: t('Working directory path'), "aria-label": t('Working directory path'), autoFocus: true }), _jsx("button", { type: "button", className: "jini-composer-workdir-confirm", "data-testid": "composer-workdir-confirm", onClick: confirmWorkdir, children: t('Set') })] })) : null] })) : null, slots?.footerAccessories ? (_jsx("div", { className: "jini-composer-footer-accessories", children: slots.footerAccessories })) : null, slots?.plusMenuItems && slots.plusMenuItems.length > 0 ? (_jsx("div", { className: "jini-composer-plus-menu", children: slots.plusMenuItems.map((item) => (_jsxs("button", { type: "button", className: "jini-composer-plus-item", onClick: () => runComposerHostEffect('plusMenuItems.onSelect', () => item.onSelect()), title: t(item.label), children: [item.icon ?? null, _jsx("span", { children: t(item.label) })] }, item.id))) })) : null, _jsxs("span", { className: "jini-composer-send-group", children: [!running || (!disabled && !sendDisabled && composer.canSubmit) ? (_jsx("button", { type: "button", className: "jini-composer-send", disabled: disabled || sendDisabled || !composer.canSubmit, onClick: onSend, title: t('Send'), "aria-label": t('Send'), children: _jsx(RemixIcon, { name: "send-plane-2-line", size: 16 }) })) : null, running ? (_jsx("button", { type: "button", className: "jini-composer-send jini-composer-send--stop", onClick: onCancel, title: t('Stop run'), "aria-label": t('Stop run'), children: _jsx(RemixIcon, { name: "stop-fill", size: 14 }) })) : null] })] })] }));
330
+ onBlur: () => composer.flushDraft?.(), onPaste: handleAttachmentPaste, ...slashTextareaAria(slashOpen, slashActiveOption), rows: 3 }), slashOpen ? (_jsx(ComposerSlashMenu, { matches: slash.matches, activeIndex: slashActiveOption, onSelect: selectSlashItem, showFilterHint: slash.showFilterHint, style: slashMenuPosition, t: t })) : null, _jsxs("div", { className: "jini-composer-footer", children: [_jsx(ComposerAttachButton, { attachmentPicker: attachmentPicker, hidden: hasDiscoveryItems, disabled: disabled, inputRef: attachmentInputRef, onChange: handleAttachmentChange, t: t }), _jsx(ComposerDiscoveryMenu, { groups: discoveryGroups, open: discoveryMenuOpen, disabled: disabled, ...(attachmentPicker ? { attachmentPicker } : {}), attachmentInputRef: attachmentInputRef, onToggle: () => setDiscoveryMenuOpen((open) => !open), onClose: () => setDiscoveryMenuOpen(false), onSelect: selectPlusItem, onAttachmentChange: handleAttachmentChange, style: discoveryMenuPosition, t: t }), _jsx(AccessorySlot, { className: "jini-composer-footer-leading", children: footerLeadingAccessory }), _jsx(ComposerWorkdirControl, { workingDirectory: workingDirectory, onChangeWorkingDirectory: onChangeWorkingDirectory, onPickWorkingDirectory: onPickWorkingDirectory, disabled: disabled, open: workdirOpen, draft: workdirDraft, position: workdirPosition, onToggle: toggleWorkdir, onDraftChange: setWorkdirDraft, onConfirm: confirmWorkdir, onClose: () => setWorkdirOpen(false), t: t }), _jsx(AccessorySlot, { className: "jini-composer-footer-accessories", children: footerAccessories }), _jsx(ComposerPlusMenu, { items: plusMenuItems, t: t }), _jsx(ComposerSendGroup, { running: running, disabled: disabled, sendDisabled: sendDisabled, canSubmit: composer.canSubmit, onSend: onSend, onCancel: onCancel, sendDisabledReason: sendDisabledReason, sendDisabledReasonId: sendDisabledReasonId, t: t })] })] }));
331
+ }
332
+ function anyPopoverOpen({ discoveryMenuOpen, slashOpen, workdirOpen }) {
333
+ return discoveryMenuOpen || slashOpen || workdirOpen;
334
+ }
335
+ /** A missing popover element counts as outside, so a stale open flag still closes. */
336
+ function isOutside(composerRoot, selector, target) {
337
+ return !composerRoot.querySelector(selector)?.contains(target);
338
+ }
339
+ /** Slash palette state derived from the live draft. */
340
+ function slashPalette({ draft, groups, dismissedSlashDraft }) {
341
+ const query = parseComposerSlashQuery({ draft });
342
+ const matches = query === null ? [] : filterComposerDiscovery({ groups, query });
343
+ return {
344
+ matches,
345
+ open: matches.length > 0 && dismissedSlashDraft !== draft,
346
+ /**
347
+ * A bare "/" intentionally matches every item (`matchesFuzzyCommand`'s own doc) so the palette
348
+ * doubles as a browse-everything view — but with nothing typed yet, nothing on screen tells a
349
+ * first-time user that continuing to type narrows it, and a fuzzy substring match against full
350
+ * descriptions stays broad for the first character or two regardless (owner-reported "it doesn't
351
+ * narrow" — narrowing DOES fire on every keystroke, this is the one state where there's no visual
352
+ * cue that it will). Shown only here, not once the user has started typing a real query — at that
353
+ * point the shrinking list is its own feedback.
354
+ */
355
+ showFilterHint: query !== null && query.command === '' && query.argument === null,
356
+ };
357
+ }
358
+ function slashTextareaAria(slashOpen, activeOption) {
359
+ if (!slashOpen)
360
+ return { 'aria-controls': undefined, 'aria-expanded': false, 'aria-activedescendant': undefined };
361
+ return { 'aria-controls': 'jini-composer-slash-menu', 'aria-expanded': true, 'aria-activedescendant': `jini-composer-slash-option-${activeOption}` };
362
+ }
363
+ function AccessorySlot({ className, children }) {
364
+ return children ? _jsx("div", { className: className, children: children }) : null;
365
+ }
366
+ /** The plain paperclip, shown only when there is no "+" discovery menu to carry attaching instead. */
367
+ function ComposerAttachButton({ attachmentPicker, hidden, disabled, inputRef, onChange, t }) {
368
+ if (!attachmentPicker || hidden)
369
+ return null;
370
+ const uploading = attachmentPicker.uploading;
371
+ const label = t(uploading ? 'Attaching files…' : 'Attach files');
372
+ return (_jsxs("div", { className: "jini-composer-attachment-picker", children: [_jsx("input", { ref: inputRef, className: "jini-composer-file-input", "data-testid": "composer-attachment-input", type: "file", multiple: true, "aria-label": t('Attach files'), accept: attachmentPicker.accept, disabled: disabled || uploading, onChange: onChange }), _jsx("button", { type: "button", className: "jini-composer-attach", disabled: disabled || uploading, onClick: () => inputRef.current?.click(), title: label, "aria-label": label, children: _jsx(RemixIcon, { name: uploading ? 'refresh-line' : 'add-line', size: 20, ...(uploading ? { className: 'jini-composer-spinner' } : {}) }) })] }));
373
+ }
374
+ function ComposerWorkdirControl({ workingDirectory, onChangeWorkingDirectory, onPickWorkingDirectory, disabled, open, draft, position, onToggle, onDraftChange, onConfirm, onClose, t }) {
375
+ if (!onChangeWorkingDirectory && !onPickWorkingDirectory)
376
+ return null;
377
+ return (_jsxs("div", { className: "jini-composer-workdir", "data-testid": "composer-workdir", children: [_jsx("button", { type: "button", className: "jini-composer-attach", "data-testid": "composer-workdir-trigger", disabled: disabled, onClick: onPickWorkingDirectory ? () => onPickWorkingDirectory() : onToggle, ...(onPickWorkingDirectory ? {} : { 'aria-haspopup': 'dialog', 'aria-expanded': open }), title: workingDirectory ?? t('Set working directory'), "aria-label": workingDirectory
378
+ ? t('Working directory: {dir}', { dir: workingDirectory })
379
+ : t('Set working directory'), children: _jsx(RemixIcon, { name: "folder-line", size: 20 }) }), !onPickWorkingDirectory && open ? (_jsxs("div", { className: "jini-composer-workdir-panel", "data-testid": "composer-workdir-panel", role: "dialog", "aria-label": t('Working directory'), style: position, children: [_jsx("input", { type: "text", className: "jini-composer-workdir-input", "data-testid": "composer-workdir-input", value: draft, onChange: (e) => onDraftChange(e.target.value), onKeyDown: (e) => {
380
+ if (e.key === 'Enter') {
381
+ e.preventDefault();
382
+ onConfirm();
383
+ }
384
+ else if (e.key === 'Escape') {
385
+ e.preventDefault();
386
+ onClose();
387
+ }
388
+ }, placeholder: t('Working directory path'), "aria-label": t('Working directory path'), autoFocus: true }), _jsx("button", { type: "button", className: "jini-composer-workdir-confirm", "data-testid": "composer-workdir-confirm", onClick: onConfirm, children: t('Set') })] })) : null] }));
389
+ }
390
+ function ComposerPlusMenu({ items, t }) {
391
+ if (!items || items.length === 0)
392
+ return null;
393
+ return (_jsx("div", { className: "jini-composer-plus-menu", children: items.map((item) => (_jsxs("button", { type: "button", className: "jini-composer-plus-item", onClick: () => runComposerHostEffect('plusMenuItems.onSelect', () => item.onSelect()), title: t(item.label), children: [item.icon ?? null, _jsx("span", { children: t(item.label) })] }, item.id))) }));
394
+ }
395
+ function ComposerSendGroup({ running, disabled, sendDisabled, canSubmit, onSend, onCancel, sendDisabledReason, sendDisabledReasonId, t }) {
396
+ const canSend = !disabled && !sendDisabled && canSubmit;
397
+ return (
398
+ // While a run streams, Send sits beside Stop once there is something to send: a message
399
+ // sent mid-run goes to the running agent (or next, if it cannot take it now). One group so
400
+ // the footer's space-between keeps the two together at the trailing edge.
401
+ _jsxs("span", { className: "jini-composer-send-group", children: [!running || canSend ? (_jsx("button", { type: "button", className: "jini-composer-send", disabled: !canSend, onClick: onSend, title: sendDisabledReason ?? t('Send'), "aria-label": t('Send'), "aria-describedby": sendDisabledReasonId, children: _jsx(RemixIcon, { name: "send-plane-2-line", size: 16 }) })) : null, running ? (_jsx("button", { type: "button", className: "jini-composer-send jini-composer-send--stop", onClick: onCancel, title: t('Stop run'), "aria-label": t('Stop run'), children: _jsx(RemixIcon, { name: "stop-fill", size: 14 }) })) : null] }));
358
402
  }
359
403
  //# sourceMappingURL=Composer.js.map
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * @module ConversationList
4
4
  *
5
- * The conversation switcher: new / select / delete / search / rename-on-double-click. Drop it
5
+ * The conversation switcher: new / select / delete / search / inline rename. Drop it
6
6
  * into `ChatPane`'s `leadingAccessory` slot — it needs no changes to `ChatPane` itself.
7
7
  *
8
8
  * Storage-agnostic by construction. It takes data and callbacks and owns nothing durable, so a
@@ -15,8 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  * when to swap is concrete — a client filter over a *paginated* list silently searches only the
16
16
  * loaded page, so the moment a host paginates, it must provide `onSearch`.
17
17
  *
18
- * Rename is double-click, restoring parity with Open Design's original `ConversationsMenu`; its
19
- * own newer in-pane menu dropped the affordance, which is a regression rather than a decision.
18
+ * Rename has an explicit keyboard-accessible action as well as the legacy double-click shortcut.
20
19
  *
21
20
  * Unstyled semantic markup with `jini-conv-*` class names, matching every other component in this
22
21
  * package — a host supplies the CSS.
@@ -26,14 +25,13 @@ import { useT } from '../hooks/context.js';
26
25
  import { Icon } from './Icon.js';
27
26
  import { useConversationListTitles } from './ConversationList.hooks.js';
28
27
  import { conversationTriggerAttributes } from './ConversationList.rules.js';
28
+ import { ConversationTitleEditor } from './ConversationTitleEditor.js';
29
29
  /**
30
30
  * How long a click waits to see whether it is really the first half of a double-click.
31
31
  *
32
- * Chosen below the ~500ms OS double-click threshold on purpose: this only has to disambiguate
33
- * two clicks a user made deliberately in quick succession, and a longer wait would make ordinary
34
- * conversation switching feel sticky.
32
+ * Covers the common OS double-click window, including deliberate clicks 300ms apart.
35
33
  */
36
- const DOUBLE_CLICK_GRACE_MS = 220;
34
+ const DOUBLE_CLICK_GRACE_MS = 500;
37
35
  function reportConversationListHostEffectFailure(effectName, error) {
38
36
  console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, error);
39
37
  }
@@ -72,8 +70,6 @@ export function ConversationList({ conversations: savedConversations, activeConv
72
70
  const [savedServerResults, setServerResults] = useState(null);
73
71
  const { conversations, serverResults, rawTitles } = useConversationListTitles({ conversations: savedConversations, serverResults: savedServerResults });
74
72
  const [editingId, setEditingId] = useState(null);
75
- const [draft, setDraft] = useState('');
76
- const originalDraftRef = useRef('');
77
73
  const rootRef = useRef(null);
78
74
  const deferredQuery = useDeferredValue(query);
79
75
  /**
@@ -97,7 +93,7 @@ export function ConversationList({ conversations: savedConversations, activeConv
97
93
  * Closing the switcher also abandons a click that has not yet resolved into a selection.
98
94
  *
99
95
  * The grace window exists so a double-click can become a rename rather than a selection, which
100
- * means a single click stays pending for 220ms. Press Escape (or click outside) inside that window
96
+ * means a single click stays pending for the grace window. Press Escape (or click outside) inside it
101
97
  * and the menu closes while the timer survives, so the row is selected anyway — and if the pending
102
98
  * click was on Delete, it selects an id that no longer exists.
103
99
  */
@@ -110,8 +106,12 @@ export function ConversationList({ conversations: savedConversations, activeConv
110
106
  useEffect(() => {
111
107
  if (!open)
112
108
  return;
109
+ // The root div is committed before any effect runs, so it is never null here. Capturing it
110
+ // (rather than reading the ref per event) keeps a late event during unmount, after React has
111
+ // detached the ref but before this cleanup runs, measured against the real node.
112
+ const root = rootRef.current;
113
113
  function onPointerDown(event) {
114
- if (!rootRef.current?.contains(event.target))
114
+ if (!root.contains(event.target))
115
115
  setOpen(false);
116
116
  }
117
117
  function onKeyDown(event) {
@@ -164,24 +164,21 @@ export function ConversationList({ conversations: savedConversations, activeConv
164
164
  // One timestamp for the whole render so every row's "5m ago" is computed against the same
165
165
  // instant — otherwise rows drawn either side of a second boundary disagree.
166
166
  const now = Date.now();
167
- const commitRename = useCallback((id) => {
168
- const next = draft.trim();
167
+ const commitRename = useCallback((id, next) => {
169
168
  setEditingId(null);
170
- // An empty rename is a cancel, not a request to erase the title — a user who selects all
171
- // and hits Enter has almost certainly changed their mind.
172
- //
173
169
  // No optimistic title update happens here — the row keeps rendering `item.title` from
174
170
  // props until the host's own state updates, so a rejection cannot leave the UI showing a
175
171
  // title the server didn't accept. Reporting the failure is what closes the remaining gap.
176
- // Merely opening and leaving the editor must not normalize a deliberately saved title.
177
- if (next && draft !== originalDraftRef.current)
178
- runConversationListHostEffect('onRename', () => onRename(id, next));
179
- }, [draft, onRename]);
172
+ runConversationListHostEffect('onRename', () => onRename(id, next));
173
+ }, [onRename]);
174
+ const startRename = (id) => {
175
+ cancelPendingSelect();
176
+ setEditingId(id);
177
+ };
180
178
  const requestDelete = useCallback((item) => {
181
179
  const confirmer = confirmDelete ??
182
- ((c) => typeof window === 'undefined'
183
- ? true
184
- : window.confirm(t('Delete "{title}"? This cannot be undone.').replace('{title}', c.title ?? t('Untitled'))));
180
+ // Only ever called from a click handler, so a browser `window` always exists here.
181
+ ((c) => window.confirm(t('Delete "{title}"? This cannot be undone.').replace('{title}', c.title ?? t('Untitled'))));
185
182
  // No optimistic removal — the row stays in `conversations` until the host's own state
186
183
  // drops it — so a rejected confirmer or `onDelete` cannot leave a deleted-looking row
187
184
  // behind; it only means the item silently never disappears. `onDelete` is the effect name
@@ -193,34 +190,23 @@ export function ConversationList({ conversations: savedConversations, activeConv
193
190
  });
194
191
  }, [confirmDelete, onDelete, t]);
195
192
  const active = conversations.find((c) => c.id === activeConversationId) ?? null;
196
- return (_jsxs("div", { className: "jini-conv", ref: rootRef, children: [_jsx("button", { type: "button", className: `jini-conv-trigger${open ? ' jini-conv-trigger-open' : ''}`, "aria-haspopup": "menu", "aria-expanded": open, ...conversationTriggerAttributes({ activeTitle: active?.title, t }, { label: triggerLabel, nativeTitle }), "data-testid": "conversation-trigger", onClick: () => setOpen((v) => !v), children: _jsx(Icon, { name: "comment", size: 16 }) }), open ? (_jsxs("div", { className: "jini-conv-menu", role: "menu", "data-testid": "conversation-menu", children: [_jsxs("div", { className: "jini-conv-head", children: [_jsxs("span", { className: "jini-conv-heading", children: [t('Conversations'), _jsx("span", { className: "jini-conv-count", "data-testid": "conversation-count", children: conversations.length })] }), _jsxs("button", { type: "button", className: "jini-conv-new", disabled: createDisabled, "data-testid": "conversation-new", onClick: () => {
197
- if (createDisabled)
198
- return;
193
+ return (_jsxs("div", { className: "jini-conv", ref: rootRef, children: [_jsx("button", { type: "button", className: `jini-conv-trigger${open ? ' jini-conv-trigger-open' : ''}`, "aria-haspopup": "menu", "aria-expanded": open, ...conversationTriggerAttributes({ activeTitle: active?.title, t }, { label: triggerLabel, nativeTitle }), "data-testid": "conversation-trigger", onClick: () => setOpen((v) => !v), children: _jsx(Icon, { name: "comment", size: 16 }) }), open ? (_jsxs("div", { className: "jini-conv-menu", role: "menu", "data-testid": "conversation-menu", children: [_jsxs("div", { className: "jini-conv-head", children: [_jsxs("span", { className: "jini-conv-heading", children: [t('Conversations'), _jsx("span", { className: "jini-conv-count", "data-testid": "conversation-count", children: conversations.length })] }), _jsxs("button", { type: "button", className: "jini-conv-new", disabled: createDisabled, "data-testid": "conversation-new",
194
+ // `disabled` alone guards this: a disabled button never dispatches a click.
195
+ onClick: () => {
199
196
  // No optimistic row is inserted — the list still comes straight from props — so a
200
197
  // rejection here cannot leave a phantom conversation on screen; it only means the
201
198
  // switcher closed without one actually being created. Reporting the failure closes
202
199
  // the remaining gap between "switcher closed" and "the host actually knows".
203
200
  runConversationListHostEffect('onCreate', () => onCreate());
204
201
  setOpen(false);
205
- }, children: [_jsx(Icon, { name: "plus", size: 12 }), _jsx("span", { children: t('New') })] })] }), _jsxs("label", { className: "jini-conv-search", children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("input", { type: "search", value: query, placeholder: t('Search conversations'), "data-testid": "conversation-search", onChange: (event) => setQuery(event.currentTarget.value) })] }), _jsx("div", { className: "jini-conv-list", "data-testid": "conversation-list", children: conversations.length === 0 ? (_jsx("div", { className: "jini-conv-empty", children: emptyState ?? t('No conversations yet') })) : visible.length === 0 ? (_jsx("div", { className: "jini-conv-empty", children: t('No conversations match') })) : (visible.map((item) => (_jsxs("div", { className: `jini-conv-item${item.id === activeConversationId ? ' jini-conv-item-active' : ''}`, "data-testid": `conversation-item-${item.id}`, children: [editingId === item.id ? (_jsx("input", { autoFocus: true, className: "jini-conv-rename", value: draft, "data-testid": `conversation-rename-${item.id}`, onChange: (event) => setDraft(event.currentTarget.value), onBlur: () => commitRename(item.id), onKeyDown: (event) => {
206
- if (event.key === 'Enter')
207
- commitRename(item.id);
208
- else if (event.key === 'Escape')
209
- setEditingId(null);
210
- } })) : (_jsxs("button", { type: "button", className: "jini-conv-item-select", "data-testid": `conversation-select-${item.id}`, title: t('Double-click to rename'), onClick: () => {
202
+ }, children: [_jsx(Icon, { name: "plus", size: 12 }), _jsx("span", { children: t('New') })] })] }), _jsxs("label", { className: "jini-conv-search", children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("input", { type: "search", value: query, placeholder: t('Search conversations'), "data-testid": "conversation-search", onChange: (event) => setQuery(event.currentTarget.value) })] }), _jsx("div", { className: "jini-conv-list", "data-testid": "conversation-list", children: conversations.length === 0 ? (_jsx("div", { className: "jini-conv-empty", children: emptyState ?? t('No conversations yet') })) : visible.length === 0 ? (_jsx("div", { className: "jini-conv-empty", children: t('No conversations match') })) : (visible.map((item) => (_jsxs("div", { className: `jini-conv-item${item.id === activeConversationId ? ' jini-conv-item-active' : ''}`, "data-testid": `conversation-item-${item.id}`, children: [editingId === item.id ? (_jsx(ConversationTitleEditor, { title: rawTitles.get(item.id) ?? null, "data-testid": `conversation-rename-${item.id}`, onCommit: (next) => commitRename(item.id, next), onCancel: () => setEditingId(null) })) : (_jsxs("button", { type: "button", className: "jini-conv-item-select", "data-testid": `conversation-select-${item.id}`, title: t('Double-click to rename'), onClick: () => {
211
203
  cancelPendingSelect();
212
204
  pendingSelectRef.current = setTimeout(() => {
213
205
  pendingSelectRef.current = null;
214
206
  onSelect(item.id);
215
207
  setOpen(false);
216
208
  }, DOUBLE_CLICK_GRACE_MS);
217
- }, onDoubleClick: () => {
218
- cancelPendingSelect();
219
- // Snapshot the raw seed: a host refresh during editing is not a user edit.
220
- originalDraftRef.current = rawTitles.get(item.id) ?? '';
221
- setEditingId(item.id);
222
- setDraft(originalDraftRef.current);
223
- }, children: [_jsx("span", { className: "jini-conv-item-title", children: item.title ?? t('Untitled') }), _jsxs("span", { className: "jini-conv-item-meta", children: [item.messageCount === undefined ? '' : `${item.messageCount} msg · `, relativeTime(item.updatedAt, now)] })] })), _jsx("button", { type: "button", className: "jini-conv-item-delete", "aria-label": t('Delete conversation'), "data-testid": `conversation-delete-${item.id}`, onClick: (event) => {
209
+ }, onDoubleClick: () => startRename(item.id), children: [_jsx("span", { className: "jini-conv-item-title", children: item.title ?? t('Untitled') }), _jsxs("span", { className: "jini-conv-item-meta", children: [item.messageCount === undefined ? '' : `${item.messageCount} msg · `, relativeTime(item.updatedAt, now)] })] })), editingId !== item.id ? _jsx("button", { type: "button", className: "jini-conv-item-rename", "aria-label": t('Rename chat'), "data-testid": `conversation-rename-button-${item.id}`, onClick: () => startRename(item.id), children: _jsx(Icon, { name: "pencil", size: 12 }) }) : null, _jsx("button", { type: "button", className: "jini-conv-item-delete", "aria-label": t('Delete conversation'), "data-testid": `conversation-delete-${item.id}`, onClick: (event) => {
224
210
  event.stopPropagation();
225
211
  requestDelete(item);
226
212
  }, children: _jsx(Icon, { name: "close", size: 12 }) })] }, item.id)))) })] })) : null] }));
@@ -0,0 +1,27 @@
1
+ export interface ConversationTitleEditorProps {
2
+ title: string | null;
3
+ onCommit: (title: string) => void | Promise<void>;
4
+ onCancel: () => void;
5
+ className?: string;
6
+ 'data-testid'?: string;
7
+ }
8
+ /**
9
+ * Shared inline rename lifecycle. Enter/blur save once; Escape, empty and unchanged drafts cancel.
10
+ * Snapshots the raw title on mount so a background title refresh cannot become a user edit.
11
+ * Host failures are reported without leaving a rejected promise unhandled.
12
+ * @complexity O(n) time/space to trim a draft of n code units; the saved title is bounded.
13
+ * @example <ConversationTitleEditor title={title} onCommit={rename} onCancel={close} />
14
+ */
15
+ export declare function ConversationTitleEditor({ title, onCommit, onCancel, className, 'data-testid': testId }: ConversationTitleEditorProps): import("react").JSX.Element;
16
+ export interface ConversationTitleProps {
17
+ title: string | null;
18
+ displayTitle: string;
19
+ onRename: (title: string) => void | Promise<void>;
20
+ disabled?: boolean;
21
+ }
22
+ /** Clickable title using the same editor as ConversationList; a host keys it by conversation ID.
23
+ * @complexity O(1) outside the shared editor's draft normalization.
24
+ * @example <ConversationTitle key={id} title={rawTitle} displayTitle={label} onRename={rename} />
25
+ */
26
+ export declare function ConversationTitle({ title, displayTitle, onRename, disabled }: ConversationTitleProps): import("react").JSX.Element;
27
+ //# sourceMappingURL=ConversationTitleEditor.d.ts.map
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { MAX_CONVERSATION_TITLE_LENGTH } from '../../core/conversation-title.js';
4
+ import { useT } from '../hooks/context.js';
5
+ /**
6
+ * Shared inline rename lifecycle. Enter/blur save once; Escape, empty and unchanged drafts cancel.
7
+ * Snapshots the raw title on mount so a background title refresh cannot become a user edit.
8
+ * Host failures are reported without leaving a rejected promise unhandled.
9
+ * @complexity O(n) time/space to trim a draft of n code units; the saved title is bounded.
10
+ * @example <ConversationTitleEditor title={title} onCommit={rename} onCancel={close} />
11
+ */
12
+ export function ConversationTitleEditor({ title, onCommit, onCancel, className = 'jini-conv-rename', 'data-testid': testId }) {
13
+ const t = useT();
14
+ const original = useRef(title ?? '');
15
+ const [draft, setDraft] = useState(original.current);
16
+ const finished = useRef(false);
17
+ // Enter/Escape can unmount a focused input and cause blur; only the first completion may act.
18
+ const finish = async ({ cancel = false } = {}) => {
19
+ if (finished.current)
20
+ return;
21
+ finished.current = true;
22
+ const next = draft.trim().slice(0, MAX_CONVERSATION_TITLE_LENGTH);
23
+ if (cancel || !next || draft === original.current) {
24
+ onCancel();
25
+ return;
26
+ }
27
+ try {
28
+ await onCommit(next);
29
+ }
30
+ catch (error) {
31
+ console.error('[@jini-ai/chat] ConversationTitleEditor onCommit host effect failed:', error);
32
+ }
33
+ };
34
+ return _jsx("input", { autoFocus: true, className: className, "aria-label": t('Rename chat'), "data-testid": testId, maxLength: MAX_CONVERSATION_TITLE_LENGTH, value: draft, onChange: (event) => setDraft(event.currentTarget.value), onBlur: () => { void finish(); }, onKeyDown: (event) => {
35
+ if (event.key === 'Enter' || event.key === 'Escape') {
36
+ event.preventDefault();
37
+ event.stopPropagation();
38
+ void finish({ cancel: event.key === 'Escape' });
39
+ }
40
+ } });
41
+ }
42
+ /** Clickable title using the same editor as ConversationList; a host keys it by conversation ID.
43
+ * @complexity O(1) outside the shared editor's draft normalization.
44
+ * @example <ConversationTitle key={id} title={rawTitle} displayTitle={label} onRename={rename} />
45
+ */
46
+ export function ConversationTitle({ title, displayTitle, onRename, disabled = false }) {
47
+ const t = useT();
48
+ const [editing, setEditing] = useState(false);
49
+ return editing ? _jsx(ConversationTitleEditor, { title: title, onCancel: () => setEditing(false), onCommit: (next) => {
50
+ setEditing(false);
51
+ return onRename(next);
52
+ } }) : _jsx("button", { type: "button", className: "jini-conv-title-button", "aria-label": t('Rename chat'), title: displayTitle, disabled: disabled, onClick: () => setEditing(true), children: displayTitle });
53
+ }
54
+ //# sourceMappingURL=ConversationTitleEditor.js.map
@@ -7,7 +7,7 @@
7
7
  * AgentIcon and WorkingDirPicker.
8
8
  */
9
9
  import type { SVGProps } from 'react';
10
- export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'copy';
10
+ export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'pencil' | 'copy';
11
11
  interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
12
12
  name: IconName;
13
13
  size?: number | string;