@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.
- package/CHANGELOG.md +12 -0
- package/dist/core/accepted-input.d.ts +54 -0
- package/dist/core/accepted-input.js +165 -0
- package/dist/core/agent-plugin-chat-state.d.ts +43 -0
- package/dist/core/agent-plugin-chat-state.js +48 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
- package/dist/core/assistant-client/messages.d.ts +1 -1
- package/dist/core/assistant-client/messages.js +2 -2
- package/dist/core/assistant-client/transport.d.ts +2 -2
- package/dist/core/assistant-client/transport.js +184 -97
- package/dist/core/assistant-content.d.ts +2 -1
- package/dist/core/assistant-content.js +9 -11
- package/dist/core/browser/page-actions.js +4 -2
- package/dist/core/composer-history.d.ts +16 -9
- package/dist/core/composer-history.js +28 -16
- package/dist/core/conversation-title.d.ts +3 -0
- package/dist/core/conversation-title.js +3 -0
- package/dist/core/durable-projection.js +20 -13
- package/dist/core/durable-runs/continuation.d.ts +4 -0
- package/dist/core/durable-runs/continuation.js +35 -6
- package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
- package/dist/core/durable-runs/policies.d.ts +1 -0
- package/dist/core/durable-runs/policies.js +1 -0
- package/dist/core/durable-runs/ports.d.ts +6 -2
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/partial-json.d.ts +6 -7
- package/dist/core/partial-json.js +81 -47
- package/dist/core/persistence/title.js +4 -2
- package/dist/core/question-answer.d.ts +23 -0
- package/dist/core/question-answer.js +66 -0
- package/dist/core/question-form/format.d.ts +6 -6
- package/dist/core/question-form/format.js +16 -17
- package/dist/core/question-form/normalize.d.ts +18 -0
- package/dist/core/question-form/normalize.js +112 -122
- package/dist/core/question-form/scan.js +141 -203
- package/dist/core/run-activity.js +62 -49
- package/dist/core/run-events/entry.d.ts +10 -1
- package/dist/core/run-events/entry.js +117 -87
- package/dist/core/todos.d.ts +1 -1
- package/dist/core/todos.js +60 -85
- package/dist/core/tool-events.js +14 -12
- package/dist/core/transcript.js +62 -16
- package/dist/core/turn-usage.d.ts +2 -1
- package/dist/core/turn-usage.js +56 -27
- package/dist/core/user-text-redaction.js +3 -0
- package/dist/core/util/manifest.js +77 -82
- package/dist/core/util/markdown-context.js +51 -53
- package/dist/core/util/parser.js +136 -144
- package/dist/core/util/pointer.js +16 -16
- package/dist/core/util/strip.js +57 -67
- package/dist/core/util/validate.js +15 -17
- package/dist/embed-widget/browser/session-store.js +21 -12
- package/dist/embed-widget/transports/fetch-sse.js +108 -95
- package/dist/embed-widget/vanilla/index.js +81 -70
- package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
- package/dist/react/components/AttachmentPreviewModal.js +25 -1
- package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
- package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
- package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
- package/dist/react/components/ChatSettingsDialog.js +72 -0
- package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
- package/dist/react/components/ChatSettingsSegmented.js +11 -0
- package/dist/react/components/Composer.d.ts +5 -1
- package/dist/react/components/Composer.js +126 -82
- package/dist/react/components/ConversationList.js +25 -39
- package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
- package/dist/react/components/ConversationTitleEditor.js +54 -0
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +25 -45
- package/dist/react/components/Markdown.js +134 -123
- package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
- package/dist/react/components/McpUiSurfaceCard.js +30 -9
- package/dist/react/components/MessageList.d.ts +13 -13
- package/dist/react/components/MessageList.hooks.d.ts +6 -1
- package/dist/react/components/MessageList.hooks.js +19 -3
- package/dist/react/components/MessageList.js +34 -10
- package/dist/react/components/MessageRow.js +48 -32
- package/dist/react/components/MessageRow.usage.d.ts +5 -2
- package/dist/react/components/MessageRow.usage.js +20 -11
- package/dist/react/components/MidRunUserMessage.js +16 -3
- package/dist/react/components/QuestionForm.js +148 -76
- package/dist/react/components/QuestionsPanel.js +1 -1
- package/dist/react/components/TodoCard.js +12 -6
- package/dist/react/components/ToolCard.js +47 -40
- package/dist/react/components/composer-discovery.js +6 -1
- package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
- package/dist/react/components/mcp-ui-surface-state.js +23 -8
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +5 -2
- package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
- package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
- package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
- package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
- package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
- package/dist/react/features/chat-pane/model-options.d.ts +18 -1
- package/dist/react/features/chat-pane/model-options.js +49 -20
- package/dist/react/features/chat-pane/rules.d.ts +8 -9
- package/dist/react/features/chat-pane/rules.js +84 -46
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +156 -37
- package/dist/react/features/chat-pane/types.d.ts +17 -8
- package/dist/react/hooks/assistant-chats.js +12 -5
- package/dist/react/hooks/composer-draft-cache.js +116 -118
- package/dist/react/hooks/composer-history-storage.js +5 -5
- package/dist/react/hooks/fab-position.js +7 -4
- package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
- package/dist/react/hooks/useAnsweredCards.js +38 -11
- package/dist/react/hooks/useArtifactStream.js +28 -22
- package/dist/react/hooks/useComposer.d.ts +20 -0
- package/dist/react/hooks/useComposer.js +22 -9
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
- package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
- package/dist/react/hooks/useConversation.d.ts +7 -0
- package/dist/react/hooks/useConversation.js +23 -10
- package/dist/react/hooks/useExtEventGroups.js +39 -39
- package/dist/react/hooks/useQuestionForms.js +21 -26
- package/dist/react/hooks/useRunStream.js +10 -2
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +9 -0
- package/dist/react/message-blocks.d.ts +6 -4
- package/dist/react/message-blocks.js +94 -72
- package/dist/react/slots.d.ts +2 -2
- package/dist/react/tool-call-display.js +19 -17
- package/dist/server/durable-runs/recover.d.ts +1 -1
- package/dist/server/durable-runs/recover.js +139 -76
- package/dist/server/run-finalizer/entry.js +25 -15
- package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
- package/dist/store/legacy/sqlite/conversations.js +69 -98
- package/dist/store/legacy/sqlite/messages.d.ts +10 -2
- package/dist/store/legacy/sqlite/messages.js +113 -71
- package/dist/store/runtime/chat-history-store.js +9 -3
- package/dist/store/runtime/durable-run-store.d.ts +2 -2
- package/dist/store/runtime/durable-run-store.js +165 -117
- package/dist/store/runtime/history-tools.js +118 -82
- package/dist/store/runtime/run-ledger.d.ts +4 -1
- package/dist/store/runtime/run-ledger.js +56 -16
- package/dist/store/sql/cursor.js +38 -18
- package/dist/store/sql/store.d.ts +18 -0
- package/dist/store/sql/store.js +46 -15
- 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
|
|
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
|
|
95
|
-
const
|
|
96
|
-
const
|
|
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:
|
|
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 (!
|
|
116
|
+
if (!anyPopoverOpen(popovers))
|
|
127
117
|
return;
|
|
128
|
-
const
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 (!
|
|
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(
|
|
226
|
-
const
|
|
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 (!
|
|
242
|
-
expectedDraft = replaceComposerSlashTrigger({ draft: composer.draft, insertText:
|
|
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(
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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: [
|
|
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,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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",
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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;
|