@jini-ai/chat 0.5.4 → 0.5.6

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 (122) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/core/ag-ui/entry.d.ts +33 -0
  3. package/dist/core/ag-ui/entry.js +80 -0
  4. package/dist/core/agentic/chat-capabilities.d.ts +2 -4
  5. package/dist/core/agentic/index.d.ts +4 -17
  6. package/dist/core/agentic/index.js +4 -17
  7. package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
  8. package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
  9. package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
  10. package/dist/core/assistant-client/ag-ui-transport.js +147 -0
  11. package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
  12. package/dist/core/assistant-client/attachment-liveness.js +93 -0
  13. package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
  14. package/dist/core/assistant-client/durable-run-subscription.js +96 -0
  15. package/dist/core/assistant-client/entry.d.ts +13 -0
  16. package/dist/core/assistant-client/entry.js +7 -0
  17. package/dist/core/assistant-client/messages.d.ts +73 -0
  18. package/dist/core/assistant-client/messages.js +68 -0
  19. package/dist/core/assistant-client/transport.d.ts +164 -0
  20. package/dist/core/assistant-client/transport.js +734 -0
  21. package/dist/core/browser/page-actions.d.ts +121 -0
  22. package/dist/core/browser/page-actions.js +113 -0
  23. package/dist/core/durable-runs/continuation.d.ts +18 -0
  24. package/dist/core/durable-runs/continuation.js +61 -0
  25. package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
  26. package/dist/core/durable-runs/ledger-contracts.js +2 -0
  27. package/dist/core/durable-runs/policies.d.ts +6 -0
  28. package/dist/core/durable-runs/policies.js +6 -0
  29. package/dist/core/durable-runs/ports.d.ts +123 -0
  30. package/dist/core/durable-runs/ports.js +2 -0
  31. package/dist/core/durable-runs/run-contracts.d.ts +17 -0
  32. package/dist/core/durable-runs/run-contracts.js +2 -0
  33. package/dist/core/durable-runs/run-lock.d.ts +4 -0
  34. package/dist/core/durable-runs/run-lock.js +4 -0
  35. package/dist/core/index.d.ts +6 -19
  36. package/dist/core/index.js +6 -19
  37. package/dist/core/messages.d.ts +2 -0
  38. package/dist/core/question-form/format.d.ts +0 -9
  39. package/dist/core/question-form/format.js +16 -2
  40. package/dist/core/question-form/normalize.js +1 -0
  41. package/dist/core/question-form/secret-answers.d.ts +38 -0
  42. package/dist/core/question-form/secret-answers.js +45 -0
  43. package/dist/core/question-form/types.d.ts +2 -0
  44. package/dist/core/question-form.d.ts +1 -0
  45. package/dist/core/question-form.js +1 -0
  46. package/dist/core/run-events/entry.d.ts +208 -16
  47. package/dist/core/run-events/entry.js +273 -39
  48. package/dist/core/transcript.d.ts +3 -2
  49. package/dist/core/transcript.js +19 -3
  50. package/dist/core/user-text-redaction.d.ts +56 -0
  51. package/dist/core/user-text-redaction.js +89 -0
  52. package/dist/embed-widget/browser/page-actions.d.ts +1 -120
  53. package/dist/embed-widget/browser/page-actions.js +2 -112
  54. package/dist/embed-widget/react/embed.d.ts +1 -1
  55. package/dist/embed-widget/react/embed.js +1 -1
  56. package/dist/react/artifact-types.d.ts +9 -15
  57. package/dist/react/components/QuestionForm.d.ts +2 -1
  58. package/dist/react/components/QuestionForm.js +18 -12
  59. package/dist/react/components/QuestionsPanel.d.ts +3 -2
  60. package/dist/react/components/QuestionsPanel.js +2 -2
  61. package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
  62. package/dist/react/features/chat-pane/agent-tools.js +0 -1
  63. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
  64. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  65. package/dist/react/features/chat-pane/components/ChatPane.js +5 -2
  66. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
  67. package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
  68. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
  69. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
  70. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +43 -22
  71. package/dist/react/features/chat-pane/model-options.d.ts +12 -0
  72. package/dist/react/features/chat-pane/model-options.js +55 -0
  73. package/dist/react/features/chat-pane/rules.d.ts +3 -2
  74. package/dist/react/features/chat-pane/rules.js +12 -4
  75. package/dist/react/features/chat-pane/types.d.ts +25 -1
  76. package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
  77. package/dist/react/hooks/assistant-chats-contracts.js +2 -0
  78. package/dist/react/hooks/assistant-chats.d.ts +7 -0
  79. package/dist/react/hooks/assistant-chats.js +960 -0
  80. package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
  81. package/dist/react/hooks/attachment-preview-cache.js +1 -1
  82. package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
  83. package/dist/react/hooks/composer-draft-cache.js +2 -2
  84. package/dist/react/hooks/fab-position.d.ts +90 -0
  85. package/dist/react/hooks/fab-position.js +271 -0
  86. package/dist/react/hooks/useConversation.d.ts +4 -1
  87. package/dist/react/hooks/useConversation.js +14 -12
  88. package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
  89. package/dist/react/hooks/useUserTextGuard.js +19 -0
  90. package/dist/react/index.d.ts +12 -21
  91. package/dist/react/index.js +7 -0
  92. package/dist/server/durable-runs/accept.d.ts +25 -0
  93. package/dist/server/durable-runs/accept.js +21 -0
  94. package/dist/server/durable-runs/entry.d.ts +5 -0
  95. package/dist/server/durable-runs/entry.js +4 -0
  96. package/dist/server/durable-runs/recover.d.ts +5 -0
  97. package/dist/server/durable-runs/recover.js +147 -0
  98. package/dist/server/run-finalizer/ports.d.ts +2 -16
  99. package/dist/store/legacy/sqlite/messages.d.ts +24 -18
  100. package/dist/store/legacy/sqlite/messages.js +12 -2
  101. package/dist/store/pglite/index.d.ts +2 -1
  102. package/dist/store/postgres/index.d.ts +2 -1
  103. package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
  104. package/dist/store/runtime/chat-expiry-sweep.js +70 -0
  105. package/dist/store/runtime/chat-history-store.d.ts +20 -0
  106. package/dist/store/runtime/chat-history-store.js +50 -0
  107. package/dist/store/runtime/durable-run-store.d.ts +21 -0
  108. package/dist/store/runtime/durable-run-store.js +342 -0
  109. package/dist/store/runtime/entry.d.ts +7 -0
  110. package/dist/store/runtime/entry.js +6 -0
  111. package/dist/store/runtime/kernel.d.ts +29 -0
  112. package/dist/store/runtime/kernel.js +2 -0
  113. package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
  114. package/dist/store/runtime/run-guarded-chat-store.js +42 -0
  115. package/dist/store/runtime/run-ledger.d.ts +15 -0
  116. package/dist/store/runtime/run-ledger.js +206 -0
  117. package/dist/store/sql/boundary.d.ts +6 -0
  118. package/dist/store/sql/boundary.js +7 -1
  119. package/dist/store/sql/store.d.ts +3 -2
  120. package/dist/store/sql/store.js +9 -2
  121. package/dist/store/sqlite/index.d.ts +3 -2
  122. package/package.json +75 -10
@@ -1,3 +1,4 @@
1
+ import { pickerModelLabel } from '../model-options.js';
1
2
  /**
2
3
  * All of `AgentRuntimePicker`'s behavior, so the component itself is only markup.
3
4
  *
@@ -146,12 +147,12 @@ export function useAgentRuntimePicker({ agents, value, placement, t, executionMo
146
147
  const [position, setPosition] = useState();
147
148
  const triggerRef = useRef(null);
148
149
  const popoverRef = useRef(null);
149
- const orderedAgents = useMemo(() => orderChatPaneAgents({ agents: agents.filter((agent) => agent.available !== false) }), [agents]);
150
+ const orderedAgents = useMemo(() => orderChatPaneAgents({ agents: agents.filter((agent) => agent.available !== false && agent.id !== 'gemini') }), [agents]);
150
151
  const selectedAgent = orderedAgents.find((agent) => agent.id === value.agentId);
151
152
  // `selectedAgent` stays resolved in BOTH modes on purpose — the CLI selection is still the stored
152
153
  // one, and switching back to Local CLI must not have silently lost it. What changes in `'api'` is
153
154
  // only what gets DESCRIBED, because none of the CLI's labels are true of an API turn.
154
- const localModelLabel = runtimeOptionLabel({ options: selectedAgent?.models, value: value.model, fallback: t('Default model') });
155
+ const localModelLabel = pickerModelLabel(selectedAgent, value.model, t);
155
156
  const byok = byokSummaryText({ byokRuntime: byokRuntime, t: t });
156
157
  const isApi = executionMode === 'api';
157
158
  const modelLabel = isApi ? byok.modelLabel : localModelLabel;
@@ -1,3 +1,4 @@
1
+ import type { UserTextRedactionOptions } from '../../../../core/user-text-redaction.js';
1
2
  import { type ChatPaneSendBlocker } from '../rules.js';
2
3
  import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
3
4
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
@@ -6,7 +7,7 @@ import type { ChatTransport } from '@jini-ai/chat';
6
7
  import { type UseComposerResult } from '../../../hooks/useComposer.js';
7
8
  import { type UseConversationResult } from '../../../hooks/useConversation.js';
8
9
  import { type UseChatPaneWorkingDirectoryResult } from './useChatPaneWorkingDirectory.hooks.js';
9
- export interface UseChatPaneOptions {
10
+ export interface UseChatPaneOptions extends UserTextRedactionOptions {
10
11
  transport: ChatTransport;
11
12
  agents: readonly ChatPaneAgent[];
12
13
  initialMessages?: ChatMessage[];
@@ -37,6 +38,8 @@ export interface UseChatPaneOptions {
37
38
  deliverTypedAnswer?: DeliverTypedAnswer;
38
39
  }
39
40
  export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
41
+ /** Show a localized non-secret notice when a paste was removed. */
42
+ secretRedacted: boolean;
40
43
  conversation: UseConversationResult;
41
44
  composer: UseComposerResult;
42
45
  selection: ChatPaneAgentSelection;
@@ -1,3 +1,4 @@
1
+ import { useUserTextGuard } from '../../../hooks/useUserTextGuard.js';
1
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
3
  import { describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, isChatPaneQueueableBlocker, isTypedAnswerTurn, resolveChatPaneSelection, } from '../rules.js';
3
4
  import { definedProps } from '../../../util/defined-props.js';
@@ -73,9 +74,11 @@ async function uploadAttachmentBatch(files, effects) {
73
74
  }
74
75
  }
75
76
  export function useChatPane(options) {
77
+ const guard = useUserTextGuard(options);
76
78
  const [activeUploadCount, setActiveUploadCount] = useState(0);
77
79
  const [attachmentError, setAttachmentError] = useState(null);
78
- const [queuedPrompt, setQueuedPrompt] = useState(null);
80
+ // Keep the signal with sanitized queue text; re-redacting it later cannot detect the original paste.
81
+ const [queuedTurn, setQueuedTurn] = useState(null);
79
82
  const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
80
83
  // Set across the await of one typed-answer delivery so a second Enter cannot post the same text
81
84
  // twice — the second post would find the question already answered and wrongly report it closed.
@@ -112,10 +115,19 @@ export function useChatPane(options) {
112
115
  workingDirectoryAccess: options.workingDirectoryAccess,
113
116
  } }));
114
117
  const requestedSelection = options.selection ?? internalSelection;
115
- const selection = useMemo(() => resolveChatPaneSelection({ agents: options.agents, requested: requestedSelection }), [options.agents, requestedSelection]);
118
+ const resolvedSelection = useMemo(() => resolveChatPaneSelection({ agents: options.agents, requested: requestedSelection }), [options.agents, requestedSelection]);
119
+ // Inline inventories and controlled selections can be new objects on every render. Keep the
120
+ // normalized value stable so synchronizing it into composer state cannot feed a render loop.
121
+ const selection = useMemo(() => definedProps({ source: {
122
+ agentId: resolvedSelection.agentId,
123
+ model: resolvedSelection.model,
124
+ reasoning: resolvedSelection.reasoning,
125
+ } }), [resolvedSelection.agentId, resolvedSelection.model, resolvedSelection.reasoning]);
116
126
  const selectedAgent = options.agents.find((agent) => agent.id === selection.agentId);
117
127
  const conversation = useConversation(definedProps({ source: {
118
128
  transport: options.transport,
129
+ redactUserText: options.redactUserText,
130
+ onSecretRedacted: guard.notifyRedaction,
119
131
  initialMessages: options.initialMessages,
120
132
  conversationId: options.conversationId,
121
133
  // Keys off an empty string, not `undefined` — `selection.agentId` is always a string (never
@@ -148,6 +160,8 @@ export function useChatPane(options) {
148
160
  activeUploadsRef.current.clear();
149
161
  };
150
162
  }, []);
163
+ const selectionControlled = options.selection !== undefined;
164
+ const setComposerAgent = composer.setAgent;
151
165
  useEffect(() => {
152
166
  if (!selection.agentId)
153
167
  return;
@@ -155,14 +169,14 @@ export function useChatPane(options) {
155
169
  && requestedSelection.model === selection.model
156
170
  && requestedSelection.reasoning === selection.reasoning)
157
171
  return;
158
- if (options.selection === undefined)
172
+ if (!selectionControlled)
159
173
  setInternalSelection(selection);
160
- composer.setAgent(selection);
174
+ setComposerAgent(selection);
161
175
  options.onSelectionChange?.(selection);
162
176
  }, [
163
- composer,
177
+ setComposerAgent,
164
178
  options.onSelectionChange,
165
- options.selection,
179
+ selectionControlled,
166
180
  requestedSelection.agentId,
167
181
  requestedSelection.model,
168
182
  requestedSelection.reasoning,
@@ -175,6 +189,7 @@ export function useChatPane(options) {
175
189
  options.onSelectionChange?.(validated);
176
190
  }, [composer, options.agents, options.onSelectionChange]);
177
191
  const sendBlocker = findChatPaneSendBlocker({
192
+ ...(selection.model ? { model: selection.model } : {}),
178
193
  selectedAgent,
179
194
  isStreaming: conversation.isStreaming,
180
195
  activeUploadCount,
@@ -267,8 +282,8 @@ export function useChatPane(options) {
267
282
  }
268
283
  }
269
284
  }, [composer, options.conversationId, options.uploadAttachments]);
270
- const sendPrompt = useCallback(async (prompt) => {
271
- const trimmed = prompt.trim();
285
+ const sendGuardedPrompt = useCallback(async ({ redaction }, _optional = {}) => {
286
+ const trimmed = redaction.text.trim();
272
287
  if (!trimmed)
273
288
  throw new Error('cannot send: the prompt is empty');
274
289
  if (sendBlocker !== null) {
@@ -287,6 +302,7 @@ export function useChatPane(options) {
287
302
  attachmentBatchIdRef.current = createAttachmentBatchId();
288
303
  options.onActivityChange?.('queued');
289
304
  await conversation.sendMessage(trimmed, definedProps({ source: {
305
+ secretRedaction: redaction.secretRedacted ? { secretRedacted: true, count: redaction.count } : undefined,
290
306
  agentId: selection.agentId,
291
307
  // Omitted when the array is EMPTY, not merely absent — an empty `attachments: []` would be a
292
308
  // different (valid, present) value to the transport than "no attachments key at all".
@@ -302,12 +318,16 @@ export function useChatPane(options) {
302
318
  sendBlocker,
303
319
  workingDirectoryState.workingDirectory,
304
320
  ]);
321
+ const sendPrompt = useCallback(async (prompt) => {
322
+ await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) }, {});
323
+ }, [guard.redact, sendGuardedPrompt]);
305
324
  const answerWaitingQuestion = useCallback(async ({ deliver, text }) => {
325
+ const safeText = guard.redactText(text);
306
326
  const generation = typedAnswerGenerationRef.current;
307
327
  const submittedDraft = latestDraftRef.current;
308
328
  const questionId = awaitedQuestionId;
309
329
  deliveringTypedAnswerRef.current = true;
310
- const outcome = await deliverTypedAnswerSafely({ deliver, text });
330
+ const outcome = await deliverTypedAnswerSafely({ deliver, text: safeText });
311
331
  deliveringTypedAnswerRef.current = false;
312
332
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
313
333
  return;
@@ -323,21 +343,21 @@ export function useChatPane(options) {
323
343
  // correction or next message typed while the host answered.
324
344
  if (latestDraftRef.current === submittedDraft)
325
345
  composer.setDraft('');
326
- }, [awaitedQuestionId, composer]);
346
+ }, [awaitedQuestionId, composer, guard.redactText]);
327
347
  // The ordinary next-turn path: queue behind a running run, else send now.
328
348
  const sendOrdinaryTurn = useCallback(async (prompt) => {
329
349
  // Queue instead of no-op'ing. `setDraft('')` and NOT `composer.reset()`: reset also discards
330
350
  // staged attachments, which this turn still needs when it finally goes out.
331
351
  if (isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
332
352
  queuedConversationIdRef.current = options.conversationId;
333
- setQueuedPrompt(prompt);
353
+ setQueuedTurn(guard.redact({ text: prompt }, {}));
334
354
  composer.setDraft('');
335
355
  return;
336
356
  }
337
357
  if (!canSend)
338
358
  return;
339
359
  await sendPrompt(prompt);
340
- }, [canSend, composer, options.conversationId, sendBlocker, sendPrompt]);
360
+ }, [canSend, composer, options.conversationId, sendBlocker, sendPrompt, guard.redact]);
341
361
  const send = useCallback(async () => {
342
362
  const prompt = composerPrompt(composer);
343
363
  if (!prompt || deliveringTypedAnswerRef.current)
@@ -385,31 +405,31 @@ export function useChatPane(options) {
385
405
  if (!prompt)
386
406
  return;
387
407
  queuedConversationIdRef.current = options.conversationId;
388
- setQueuedPrompt(prompt);
408
+ setQueuedTurn(guard.redact({ text: prompt }, {}));
389
409
  composer.setDraft('');
390
410
  conversation.cancel();
391
- }, [composer, conversation, options.conversationId]);
411
+ }, [composer, conversation, options.conversationId, guard.redact]);
392
412
  const cancelQueued = useCallback(() => {
393
413
  queuedConversationIdRef.current = undefined;
394
- setQueuedPrompt(null);
414
+ setQueuedTurn(null);
395
415
  }, []);
396
416
  // Flush on a FULLY clear blocker, not merely on streaming ending — see
397
417
  // `isChatPaneQueueableBlocker`'s note about `findChatPaneSendBlocker`'s ordering. Clearing the
398
418
  // queue slot BEFORE awaiting keeps a re-render from double-sending the same prompt.
399
419
  useEffect(() => {
400
- if (queuedPrompt === null || sendBlocker !== null)
420
+ if (queuedTurn === null || sendBlocker !== null)
401
421
  return;
402
422
  if (queuedConversationIdRef.current !== options.conversationId) {
403
423
  // The conversation changed while this prompt waited behind a streaming run — sending it now
404
424
  // would post it into a conversation the user never saw it queued against. Drop it instead
405
425
  // (same as `cancelQueued`) rather than misrouting it.
406
426
  queuedConversationIdRef.current = undefined;
407
- setQueuedPrompt(null);
427
+ setQueuedTurn(null);
408
428
  return;
409
429
  }
410
- setQueuedPrompt(null);
411
- void sendPrompt(queuedPrompt);
412
- }, [options.conversationId, queuedPrompt, sendBlocker, sendPrompt]);
430
+ setQueuedTurn(null);
431
+ void sendGuardedPrompt({ redaction: queuedTurn }, {});
432
+ }, [options.conversationId, queuedTurn, sendBlocker, sendGuardedPrompt]);
413
433
  const reset = useCallback(() => {
414
434
  attachmentGenerationRef.current += 1;
415
435
  attachmentBatchIdRef.current = createAttachmentBatchId();
@@ -428,9 +448,10 @@ export function useChatPane(options) {
428
448
  // `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
429
449
  // the just-reset conversation instead of discarding it like the rest of this turn's state.
430
450
  queuedConversationIdRef.current = undefined;
431
- setQueuedPrompt(null);
451
+ setQueuedTurn(null);
432
452
  }, [composer, conversation, options.initialMessages]);
433
453
  return {
454
+ secretRedacted: guard.secretRedacted,
434
455
  conversation,
435
456
  composer,
436
457
  selection,
@@ -446,7 +467,7 @@ export function useChatPane(options) {
446
467
  addAttachments,
447
468
  send,
448
469
  sendPrompt,
449
- queuedPrompt,
470
+ queuedPrompt: queuedTurn?.text ?? null,
450
471
  cancelQueued,
451
472
  typedAnswerNotice,
452
473
  sendAsNewMessage,
@@ -0,0 +1,12 @@
1
+ import type { I18nAdapter } from '../../slots.js';
2
+ import type { ChatPaneAgent, ChatPaneAgentOption } from './types.js';
3
+ export declare function isConcretePickerModel(id: string): boolean;
4
+ /** Aliases are selectable only as their evidence-backed concrete ID. Never render a Default row.
5
+ * @complexity O(n) time and space in catalog rows, with set-based deduplication.
6
+ */
7
+ export declare function pickerModelOptions(agent: ChatPaneAgent): ChatPaneAgentOption[];
8
+ export declare function configuredPickerModel(agent: ChatPaneAgent): string | undefined;
9
+ export declare function pickerModelLabel(agent: ChatPaneAgent | undefined, requested?: string, t?: I18nAdapter['t']): string;
10
+ export declare function pickerModelValue(agent: ChatPaneAgent, requested?: string): string;
11
+ export declare function pickerCatalogNote(agent: ChatPaneAgent, t?: I18nAdapter['t']): string | undefined;
12
+ //# sourceMappingURL=model-options.d.ts.map
@@ -0,0 +1,55 @@
1
+ // Model names and timestamps are metadata; only surrounding picker copy is translated.
2
+ const passthrough = key => key;
3
+ export function isConcretePickerModel(id) {
4
+ const token = id.slice(id.lastIndexOf('/') + 1);
5
+ return !/^(?:opus|sonnet|haiku|fable|best|latest|claude|gemini|gpt|codex|swe)(?:\[.*\])?$/i.test(token) && !/^(default|auto|adaptive|smart|deep|rush|ultimate|premium|balanced|fast|grok-build|vercel-ai-gateway)$/i.test(token) && !/(?:^|[-/])(gateway|latest)$/i.test(id);
6
+ }
7
+ /** Aliases are selectable only as their evidence-backed concrete ID. Never render a Default row.
8
+ * @complexity O(n) time and space in catalog rows, with set-based deduplication.
9
+ */
10
+ export function pickerModelOptions(agent) {
11
+ if (agent.supportsConcreteModelSelection === false)
12
+ return [];
13
+ const seen = new Set();
14
+ const options = (agent.models || []).flatMap((row) => {
15
+ const id = row.resolvedId || (row.identityKind === 'routing-mode' || row.identityKind === 'alias' ? undefined : row.id);
16
+ if (!id || !isConcretePickerModel(id) || seen.has(id))
17
+ return [];
18
+ seen.add(id);
19
+ // Older cached inventories can attach a default-policy label to a resolved concrete ID.
20
+ const label = row.id === 'default' || /^default\b/i.test(row.label) ? id : row.label;
21
+ return [{ ...row, id, label, identityKind: 'concrete' }];
22
+ });
23
+ const resolution = agent.defaultModelResolution;
24
+ // A host may publish a resolved default outside its visible catalog. Preserve its concrete ID.
25
+ if (resolution?.status === 'resolved' && isConcretePickerModel(resolution.id) && !seen.has(resolution.id))
26
+ options.unshift({ id: resolution.id, label: resolution.id, identityKind: 'concrete' });
27
+ return options;
28
+ }
29
+ export function configuredPickerModel(agent) {
30
+ if (agent.supportsConcreteModelSelection === false)
31
+ return undefined;
32
+ const resolution = agent.defaultModelResolution;
33
+ return resolution?.status === 'resolved' && isConcretePickerModel(resolution.id) ? resolution.id : undefined;
34
+ }
35
+ export function pickerModelLabel(agent, requested, t = passthrough) {
36
+ if (!agent)
37
+ return t('Choose model');
38
+ const id = !requested || requested === 'default' ? configuredPickerModel(agent) : requested;
39
+ if (!id || !isConcretePickerModel(id))
40
+ return t('Choose model');
41
+ return pickerModelOptions(agent).find((row) => row.id === id)?.label || id;
42
+ }
43
+ export function pickerModelValue(agent, requested) {
44
+ const id = requested && requested !== 'default' ? requested : configuredPickerModel(agent);
45
+ return id && pickerModelOptions(agent).some((row) => row.id === id) ? id : '';
46
+ }
47
+ export function pickerCatalogNote(agent, t = passthrough) {
48
+ const catalog = agent.modelCatalog;
49
+ if (catalog?.freshness === 'stale')
50
+ return `${t('Cached')} · ${catalog.fetchedAt}`;
51
+ if (catalog?.freshness === 'offline-fallback' || !catalog && agent.models)
52
+ return t('Offline fallback');
53
+ return undefined;
54
+ }
55
+ //# sourceMappingURL=model-options.js.map
@@ -14,9 +14,10 @@ export declare function resolveChatPaneSelection({ agents, requested }: {
14
14
  * precondition (`composer.canSubmit`), not a property of the pane. An agent-driven send supplies
15
15
  * its own prompt and must still clear every blocker below.
16
16
  */
17
- export type ChatPaneSendBlocker = 'no-agent-selected' | 'agent-unavailable' | 'streaming' | 'uploads-pending' | 'working-directory-pending' | 'working-directory-invalid' | 'working-directory-error';
17
+ export type ChatPaneSendBlocker = 'no-agent-selected' | 'agent-unavailable' | 'model-unresolved' | 'streaming' | 'uploads-pending' | 'working-directory-pending' | 'working-directory-invalid' | 'working-directory-error';
18
18
  export interface ChatPaneSendability {
19
- readonly selectedAgent: Pick<ChatPaneAgent, 'available'> | undefined;
19
+ readonly selectedAgent: Pick<ChatPaneAgent, 'available' | 'modelCatalog' | 'supportsConcreteModelSelection'> | undefined;
20
+ readonly model?: string;
20
21
  readonly isStreaming: boolean;
21
22
  readonly activeUploadCount: number;
22
23
  readonly workingDirectoryPending: boolean;
@@ -1,3 +1,4 @@
1
+ import { configuredPickerModel, pickerModelOptions, isConcretePickerModel } from './model-options.js';
1
2
  function preferredOptionId(options) {
2
3
  return options?.find((option) => option.id === 'default')?.id ?? options?.[0]?.id;
3
4
  }
@@ -7,7 +8,9 @@ function validatedOptionId(options, requested, fallback) {
7
8
  return fallback;
8
9
  }
9
10
  export function defaultChatPaneSelection({ agent }) {
10
- const model = preferredOptionId(agent.models);
11
+ // An unresolved native route requires an intentional concrete choice. Legacy hosts without
12
+ // structured resolution retain their first-concrete-entry behavior.
13
+ const model = configuredPickerModel(agent) || (agent.defaultModelResolution?.status === 'unresolved' ? undefined : pickerModelOptions(agent)[0]?.id);
11
14
  const reasoning = preferredOptionId(agent.reasoningOptions);
12
15
  return {
13
16
  agentId: agent.id,
@@ -16,17 +19,19 @@ export function defaultChatPaneSelection({ agent }) {
16
19
  };
17
20
  }
18
21
  export function resolveChatPaneSelection({ agents, requested }) {
19
- const requestedAgent = agents.find((agent) => agent.id === requested.agentId && agent.available !== false);
20
- const agent = requestedAgent ?? agents.find((candidate) => candidate.available !== false);
22
+ const requestedAgent = agents.find((agent) => agent.id === requested.agentId && agent.id !== 'gemini' && agent.available !== false);
23
+ const agent = requestedAgent ?? agents.find((candidate) => candidate.id !== 'gemini' && candidate.available !== false);
21
24
  if (!agent)
22
25
  return { agentId: '' };
23
26
  const defaults = defaultChatPaneSelection({ agent: agent });
24
27
  if (agent !== requestedAgent)
25
28
  return defaults;
26
29
  const model = requested.model
30
+ && isConcretePickerModel(requested.model)
27
31
  && agent.supportsCustomModel
32
+ && agent.supportsConcreteModelSelection !== false
28
33
  ? requested.model
29
- : validatedOptionId(agent.models, requested.model, defaults.model);
34
+ : validatedOptionId(pickerModelOptions(agent), requested.model, defaults.model);
30
35
  const reasoning = validatedOptionId(agent.reasoningOptions, requested.reasoning, defaults.reasoning);
31
36
  return {
32
37
  agentId: agent.id,
@@ -64,6 +69,8 @@ export function findChatPaneSendBlocker(state) {
64
69
  else if (state.selectedAgent.available === false) {
65
70
  return 'agent-unavailable';
66
71
  }
72
+ if (!state.apiModeConfigured && state.selectedAgent && (state.selectedAgent.modelCatalog || state.selectedAgent.supportsConcreteModelSelection === false) && (!state.model || !isConcretePickerModel(state.model) || state.selectedAgent.supportsConcreteModelSelection === false))
73
+ return 'model-unresolved';
67
74
  if (state.isStreaming)
68
75
  return 'streaming';
69
76
  if (state.activeUploadCount > 0)
@@ -79,6 +86,7 @@ export function findChatPaneSendBlocker(state) {
79
86
  const SEND_BLOCKER_MESSAGES = {
80
87
  'no-agent-selected': 'no agent is selected',
81
88
  'agent-unavailable': 'the selected agent is unavailable',
89
+ 'model-unresolved': 'choose a concrete model before sending',
82
90
  streaming: 'a run is already streaming — cancel it first',
83
91
  'uploads-pending': 'attachment uploads are still in flight',
84
92
  'working-directory-pending': 'the working directory is still being validated',
@@ -1,3 +1,4 @@
1
+ import type { UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
1
2
  import type { CSSProperties, ReactNode, RefObject } from 'react';
2
3
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
3
4
  import type { ComposerSlots } from '../../slots.js';
@@ -6,6 +7,8 @@ import type { ChatTransport, RunContext } from '@jini-ai/chat';
6
7
  export interface ChatPaneAgentOption {
7
8
  id: string;
8
9
  label: string;
10
+ identityKind?: 'concrete' | 'alias' | 'routing-mode';
11
+ resolvedId?: string;
9
12
  }
10
13
  /**
11
14
  * Browser-safe runtime inventory consumed by the chat pane. This is
@@ -19,8 +22,29 @@ export interface ChatPaneAgent {
19
22
  version?: string | null;
20
23
  authStatus?: 'ok' | 'missing' | 'unknown';
21
24
  models?: readonly ChatPaneAgentOption[];
25
+ modelCatalog?: {
26
+ source: 'cli' | 'rpc' | 'provider-api' | 'cli-cache' | 'config-file' | 'offline-fallback';
27
+ freshness: 'fresh' | 'stale' | 'offline-fallback';
28
+ fetchedAt: string;
29
+ expiresAt: string;
30
+ coverage: 'account' | 'provider' | 'configured' | 'observed';
31
+ launchFingerprint: string;
32
+ };
33
+ defaultModelResolution?: {
34
+ status: 'resolved';
35
+ id: string;
36
+ selectionId?: string;
37
+ source: string;
38
+ resolvedAt: string;
39
+ launchFingerprint: string;
40
+ } | {
41
+ status: 'unresolved';
42
+ selectionId?: string;
43
+ reason: string;
44
+ };
22
45
  reasoningOptions?: readonly ChatPaneAgentOption[];
23
46
  supportsCustomModel?: boolean;
47
+ supportsConcreteModelSelection?: boolean;
24
48
  diagnostic?: string;
25
49
  /**
26
50
  * Whether this runtime can receive external MCP servers (host/Jini tools) at all — mirrors
@@ -143,7 +167,7 @@ export type DeliverTypedAnswer = ((input: {
143
167
  }) => Promise<TypedAnswerDelivery>) & {
144
168
  readonly toolName: string;
145
169
  };
146
- export interface ChatPaneProps {
170
+ export interface ChatPaneProps extends UserTextRedactionOptions {
147
171
  transport: ChatTransport;
148
172
  agents?: readonly ChatPaneAgent[];
149
173
  /** Optional host effects for package-owned inventory, rescan, and health polling. */
@@ -0,0 +1,164 @@
1
+ import type { ChatMessage } from "../../core/messages.js";
2
+ import type { AssistantConversation } from "../../core/assistant-client/messages.js";
3
+ /**
4
+ * @file What `useAssistantChats` needs from the outside world, as an interface rather than a set of
5
+ * module imports.
6
+ *
7
+ * The hook used to import `lib/assistant-chats`'s functions directly, which meant the only way to
8
+ * test it was to stub the global `fetch` and assert on the URLs and methods that came out — the
9
+ * tests described HTTP when what they meant to describe was "which conversation did this message
10
+ * get written to". Worse, behaviour that has nothing to do with transport (the write retry and its
11
+ * backoff) could only be exercised by hand-building `Response` objects with the right status codes.
12
+ *
13
+ * Declaring the seam here follows the `useX(dependencies)` / `useWiredX()` pair this workspace
14
+ * already uses throughout `@jini-ai/ui` (see `features/html-viewer/react/hooks/usePresentMode.ts`
15
+ * for the canonical shape, and `foundry/docs/jini-port/skills/fixing-open-design-web.md` for the
16
+ * rules). `ports.ts` declares, `assistant-chats-dependencies.ts` binds, and nothing else imports the
17
+ * real provider.
18
+ *
19
+ * `AssistantConversation` is imported here `import type`, which is erased at compile time — so this
20
+ * file still has no runtime dependency on `lib/assistant-chats`, and `dependencies.ts` remains the
21
+ * single place the real implementation is reached.
22
+ *
23
+ * ## What is deliberately NOT in this port
24
+ *
25
+ * `persistableMessages` and `HttpError`. The first is a pure rule — no I/O, no host — and per the
26
+ * pattern a hook imports rules directly rather than having them injected; making it swappable would
27
+ * let a fake quietly change which messages count as settled, which is the one thing every test here
28
+ * needs to hold still. The second is the error type an implementation is expected to throw for a
29
+ * non-2xx response, and it is part of this contract even though it is not a method: {@link
30
+ * AssistantChatsPort.saveMessage}'s retry classifies failures by it.
31
+ */
32
+ export interface AssistantChatsPort {
33
+ listConversations(required: Record<string, never>, optional: Record<string, never>): Promise<AssistantConversation[]>;
34
+ /** `firstMessage` seeds the server-side title heuristic; omit it for an untitled empty chat. */
35
+ createConversation(required: {
36
+ firstMessage?: string;
37
+ }, optional: Record<string, never>): Promise<AssistantConversation>;
38
+ renameConversation(required: {
39
+ id: string;
40
+ title: string;
41
+ }, optional: Record<string, never>): Promise<void>;
42
+ deleteConversation(required: {
43
+ id: string;
44
+ }, optional: Record<string, never>): Promise<void>;
45
+ loadMessages(required: {
46
+ conversationId: string;
47
+ }, optional: Record<string, never>): Promise<ChatMessage[]>;
48
+ /**
49
+ * Writes one message.
50
+ *
51
+ * Expected to reject with an `HttpError` for a non-2xx response and with anything else for a
52
+ * transport failure — the hook retries the second kind, and the first only for 429/5xx. An
53
+ * implementation that swallows failures into a resolved promise silently disables the retry.
54
+ */
55
+ saveMessage(required: {
56
+ conversationId: string;
57
+ message: ChatMessage;
58
+ }, optional: Record<string, never>): Promise<void>;
59
+ }
60
+ export interface UseAssistantChats {
61
+ conversations: AssistantConversation[];
62
+ activeId: string | null;
63
+ /**
64
+ * `ChatPane`'s `key`. Deliberately NOT `activeId`.
65
+ *
66
+ * Remounting the pane is correct for a user-initiated switch and destructive at any other time: it
67
+ * re-seeds from `initialMessages` and discards whatever the pane currently holds. `activeId` now
68
+ * also changes when an untitled pane silently adopts a freshly created conversation mid-run (see
69
+ * `onMessagesChange`), and re-keying on that would wipe the reply being streamed. So the two are
70
+ * separate: this changes only on `select`/`create`/`remove`, `activeId` tracks where writes go.
71
+ */
72
+ paneKey: string;
73
+ /** Messages to seed the pane with. Changes identity only on a real conversation switch. */
74
+ initialMessages: ChatMessage[];
75
+ select: (required: {
76
+ id: string;
77
+ }, optional?: Record<string, never>) => void;
78
+ create: (required: Record<string, never>, optional?: Record<string, never>) => Promise<void>;
79
+ remove: (required: {
80
+ id: string;
81
+ }, optional?: Record<string, never>) => Promise<void>;
82
+ rename: (required: {
83
+ id: string;
84
+ title: string;
85
+ }, optional?: Record<string, never>) => Promise<void>;
86
+ onMessagesChange: (required: {
87
+ messages: ChatMessage[];
88
+ }, optional?: Record<string, never>) => void;
89
+ /**
90
+ * This pane's conversation id, adopting one if none is active yet. Awaited by
91
+ * `assistant-transport.ts`'s `startRun` so turn 1's run carries the identity its agent-CLI session
92
+ * id gets filed under — see the hook's own implementation doc for the amnesia defect that needs it.
93
+ * Resolves `null` (never rejects) when creation fails.
94
+ */
95
+ ensureConversationId: (required: Record<string, never>, optional?: Record<string, never>) => Promise<string | null>;
96
+ /**
97
+ * Writes one user turn durably, awaited by `assistant-transport.ts`'s `startRun` BEFORE it
98
+ * dispatches the run — see the implementation's own doc for the defect (a failed run's user
99
+ * message reaching no agent and no later prompt). Never rejects; a failed write releases the id
100
+ * so the delta-driven `flush` can still pick it up.
101
+ */
102
+ persistUserTurn: (required: {
103
+ conversationId: string;
104
+ message: ChatMessage;
105
+ }, optional?: Record<string, never>) => Promise<void>;
106
+ }
107
+ export interface AssistantChatsClientPorts {
108
+ readonly statusOf: (required: {
109
+ error: unknown;
110
+ }, optional: Record<string, never>) => number | undefined;
111
+ readonly sleep: (required: {
112
+ delayMs: number;
113
+ }, optional: Record<string, never>) => Promise<void>;
114
+ readonly reportPermanent: (required: {
115
+ conversationId: string;
116
+ messageId: string;
117
+ error: unknown;
118
+ }, optional: Record<string, never>) => void;
119
+ readonly requestRunPrefixes: readonly string[];
120
+ }
121
+ export type SaveOutcome = "saved" | "exhausted" | "permanent" | "missing";
122
+ export type AttemptResult = {
123
+ done: true;
124
+ outcome: SaveOutcome;
125
+ } | {
126
+ done: false;
127
+ };
128
+ export interface SaveFailureInput {
129
+ error: unknown;
130
+ conversationId: string;
131
+ message: ChatMessage;
132
+ attempt: number;
133
+ isDisposed: () => boolean;
134
+ }
135
+ export interface AssistantChatsClient {
136
+ giveUpOutcome(required: {
137
+ error: unknown;
138
+ }, optional?: Record<string, never>): SaveOutcome;
139
+ handleSaveFailure(required: SaveFailureInput, optional?: Record<string, never>): Promise<AttemptResult>;
140
+ attemptSave(required: Omit<SaveFailureInput, "error"> & {
141
+ port: AssistantChatsPort;
142
+ }, optional?: Record<string, never>): Promise<AttemptResult>;
143
+ summarizeFlushOutcomes(required: {
144
+ results: readonly {
145
+ message: ChatMessage;
146
+ outcome: SaveOutcome;
147
+ }[];
148
+ isConversationStillActive: boolean;
149
+ }, optional?: Record<string, never>): {
150
+ idsToRelease: string[];
151
+ shouldRefresh: boolean;
152
+ };
153
+ resolveRememberedConversation(required: {
154
+ remembered: string | null;
155
+ conversations: readonly AssistantConversation[];
156
+ listFailed: boolean;
157
+ }, optional?: Record<string, never>): "restore" | "forget" | "keep";
158
+ useAssistantChats(required: {
159
+ port: AssistantChatsPort;
160
+ }, optional?: {
161
+ lastConversation?: import("./last-conversation-store.js").LastConversationStore;
162
+ }): UseAssistantChats;
163
+ }
164
+ //# sourceMappingURL=assistant-chats-contracts.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=assistant-chats-contracts.js.map
@@ -0,0 +1,7 @@
1
+ import type { AssistantChatsClientPorts, AssistantChatsClient } from "./assistant-chats-contracts.js";
2
+ /** Bind effects once; the returned hook captures its conversation port at mount.
3
+ * Existing race guards and retry ordering remain in the original lexical lifecycle. */
4
+ export declare function createAssistantChatsClient({ ports }: {
5
+ ports: AssistantChatsClientPorts;
6
+ }, _options?: Record<string, never>): AssistantChatsClient;
7
+ //# sourceMappingURL=assistant-chats.d.ts.map