@jini-ai/chat 0.5.6 → 0.5.8
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/dist/core/ag-ui/entry.js +2 -0
- package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -1
- package/dist/core/assistant-client/durable-run-subscription.js +13 -4
- package/dist/core/durable-projection.d.ts +3 -1
- package/dist/core/durable-projection.js +40 -7
- package/dist/core/events.d.ts +3 -0
- package/dist/core/history-tool-metadata.d.ts +23 -0
- package/dist/core/history-tool-metadata.js +13 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +4 -0
- package/dist/core/persistence/title.d.ts +24 -0
- package/dist/core/persistence/title.js +60 -9
- package/dist/core/run-activity.d.ts +15 -2
- package/dist/core/run-activity.js +6 -4
- package/dist/core/run-events/entry.d.ts +12 -1
- package/dist/core/run-events/entry.js +19 -6
- package/dist/core/tool-call-detail.d.ts +51 -0
- package/dist/core/tool-call-detail.js +67 -0
- package/dist/core/turn-usage.d.ts +50 -0
- package/dist/core/turn-usage.js +114 -0
- package/dist/core/usage-format.d.ts +10 -0
- package/dist/core/usage-format.js +15 -0
- package/dist/embed-widget/browser/lazy-loader.d.ts +34 -0
- package/dist/embed-widget/browser/lazy-loader.js +116 -0
- package/dist/embed-widget/react/dialog.hooks.d.ts +28 -0
- package/dist/embed-widget/react/dialog.hooks.js +54 -0
- package/dist/embed-widget/react/embed.d.ts +1 -0
- package/dist/embed-widget/react/embed.js +5 -3
- package/dist/embed-widget/react/header.d.ts +3 -1
- package/dist/embed-widget/react/header.hooks.d.ts +13 -0
- package/dist/embed-widget/react/header.hooks.js +32 -0
- package/dist/embed-widget/react/header.js +4 -14
- package/dist/embed-widget/vanilla/index.d.ts +63 -0
- package/dist/embed-widget/vanilla/index.js +319 -0
- package/dist/embed-widget/vanilla/state.d.ts +48 -0
- package/dist/embed-widget/vanilla/state.js +120 -0
- package/dist/embed-widget/vanilla/style.css +29 -0
- package/dist/react/chat-pane.d.ts +2 -2
- package/dist/react/chat-pane.js +1 -1
- package/dist/react/components/A2uiSurfaceCard.d.ts +5 -1
- package/dist/react/components/A2uiSurfaceCard.js +9 -9
- package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +7 -0
- package/dist/react/components/AttachmentPreviewModal.hooks.js +1 -1
- package/dist/react/components/ChatFab.d.ts +4 -1
- package/dist/react/components/ChatFab.js +2 -2
- package/dist/react/components/Composer.d.ts +2 -0
- package/dist/react/components/Composer.js +5 -2
- package/dist/react/components/ConversationList.d.ts +8 -1
- package/dist/react/components/ConversationList.hooks.d.ts +18 -0
- package/dist/react/components/ConversationList.hooks.js +25 -0
- package/dist/react/components/ConversationList.js +12 -5
- package/dist/react/components/ConversationList.rules.d.ts +25 -0
- package/dist/react/components/ConversationList.rules.js +19 -0
- package/dist/react/components/Markdown.js +6 -6
- package/dist/react/components/McpUiSurfaceCard.d.ts +1 -1
- package/dist/react/components/McpUiSurfaceCard.js +8 -4
- package/dist/react/components/MessageList.d.ts +8 -13
- package/dist/react/components/MessageList.hooks.d.ts +4 -0
- package/dist/react/components/MessageList.hooks.js +6 -1
- package/dist/react/components/MessageList.js +23 -4
- package/dist/react/components/MessageRow.d.ts +23 -1
- package/dist/react/components/MessageRow.js +61 -36
- package/dist/react/components/MessageRow.usage.d.ts +48 -0
- package/dist/react/components/MessageRow.usage.js +47 -0
- package/dist/react/components/MidRunUserMessage.d.ts +17 -0
- package/dist/react/components/MidRunUserMessage.js +41 -0
- package/dist/react/components/SentAttachment.d.ts +19 -0
- package/dist/react/components/SentAttachment.hooks.d.ts +44 -0
- package/dist/react/components/SentAttachment.hooks.js +56 -0
- package/dist/react/components/SentAttachment.js +14 -0
- package/dist/react/components/ToolCallGroup.d.ts +12 -0
- package/dist/react/components/ToolCallGroup.hooks.d.ts +20 -0
- package/dist/react/components/ToolCallGroup.hooks.js +23 -0
- package/dist/react/components/ToolCallGroup.js +13 -0
- package/dist/react/components/ToolCard.js +41 -22
- package/dist/react/components/mcp-ui-surface-state.d.ts +21 -0
- package/dist/react/components/mcp-ui-surface-state.js +26 -0
- package/dist/react/ext-event-renderer-registry.d.ts +7 -0
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +20 -7
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +44 -0
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +69 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +65 -10
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.d.ts +25 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +70 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +4 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.d.ts +9 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.js +11 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.d.ts +17 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.js +15 -0
- package/dist/react/features/chat-pane/index.d.ts +2 -1
- package/dist/react/features/chat-pane/index.js +1 -0
- package/dist/react/features/chat-pane/rules.d.ts +27 -0
- package/dist/react/features/chat-pane/rules.js +32 -0
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +92 -1
- package/dist/react/features/chat-pane/types.d.ts +39 -1
- package/dist/react/hooks/composer-draft-cache.d.ts +15 -0
- package/dist/react/hooks/composer-draft-cache.js +20 -0
- package/dist/react/hooks/useAnsweredCards.d.ts +20 -0
- package/dist/react/hooks/useAnsweredCards.js +23 -0
- package/dist/react/hooks/useComposer.d.ts +14 -0
- package/dist/react/hooks/useComposer.js +78 -6
- package/dist/react/hooks/useConversation.d.ts +8 -0
- package/dist/react/hooks/useConversation.js +34 -0
- package/dist/react/hooks/useRunActivity.d.ts +5 -3
- package/dist/react/hooks/useRunActivity.js +4 -3
- package/dist/react/index.d.ts +8 -2
- package/dist/react/index.js +6 -1
- package/dist/react/message-blocks.d.ts +22 -3
- package/dist/react/message-blocks.js +25 -3
- package/dist/react/tool-call-display.d.ts +90 -0
- package/dist/react/tool-call-display.js +97 -0
- package/dist/server/durable-runs/recover.js +19 -11
- package/dist/server/run-finalizer/entry.js +2 -1
- package/dist/store/ports.d.ts +25 -2
- package/dist/store/runtime/durable-run-store.js +5 -2
- package/dist/store/runtime/entry.d.ts +1 -0
- package/dist/store/runtime/entry.js +1 -0
- package/dist/store/runtime/history-tools.d.ts +27 -0
- package/dist/store/runtime/history-tools.js +169 -0
- package/dist/store/runtime/run-ledger.js +5 -2
- package/dist/store/sql/store.js +52 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +22 -0
- package/package.json +48 -18
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The expanded body of a tool-call row: what the agent sent, what came back, and — when nothing
|
|
3
|
+
* came back — why. Shared by every card in `react/components/ToolCard.tsx` so no row kind (a CLI
|
|
4
|
+
* built-in like WebFetch, an unknown MCP tool, a card tool such as `assistant_ask_choice` still
|
|
5
|
+
* waiting on the person's answer) is left without a readable call.
|
|
6
|
+
*/
|
|
7
|
+
import { formatToolOutputForDisplay } from './tool-output.js';
|
|
8
|
+
import { redactUserText } from './user-text-redaction.js';
|
|
9
|
+
const DEFAULT_MAX_CHARS = 10_000;
|
|
10
|
+
/**
|
|
11
|
+
* Builds the readable detail for one tool call.
|
|
12
|
+
*
|
|
13
|
+
* @param required.input - The `tool_use` event's input, any shape.
|
|
14
|
+
* @param required.result - The paired `tool_result`, when one arrived.
|
|
15
|
+
* @param options - Run state that explains a missing result, the display cap, and the redactor.
|
|
16
|
+
* @returns Display-ready input and output text plus a note when the output is absent.
|
|
17
|
+
* @complexity O(n) in the serialized input plus result length.
|
|
18
|
+
*/
|
|
19
|
+
export function describeToolCallDetail({ input, result }, { runStreaming = false, runSucceeded = false, maxChars = DEFAULT_MAX_CHARS, redact = redactUserText } = {}) {
|
|
20
|
+
const show = (text) => (text === null ? null : truncate(redact({ text: text }).text, maxChars));
|
|
21
|
+
const inputText = show(serializeInput(input));
|
|
22
|
+
if (!result)
|
|
23
|
+
return { inputText, outputText: null, noteKey: missingResultNote({ runStreaming, runSucceeded }) };
|
|
24
|
+
if (!result.content.trim())
|
|
25
|
+
return { inputText, outputText: null, noteKey: result.isError ? 'Failed without an error message.' : 'Finished with no output.' };
|
|
26
|
+
return { inputText, outputText: show(formatToolOutputForDisplay({ text: result.content })), noteKey: null };
|
|
27
|
+
}
|
|
28
|
+
/** Per-block cap for the text a collapsed row keeps mounted — see {@link describeCollapsedToolCall}. */
|
|
29
|
+
export const COLLAPSED_DETAIL_MAX_CHARS = 400;
|
|
30
|
+
/**
|
|
31
|
+
* The short, single-node text a collapsed row keeps in the DOM in place of its full detail. A long
|
|
32
|
+
* chat mounts hundreds of collapsed rows (713 in a 38-message chat, 3.45 MB of hidden text at the
|
|
33
|
+
* full 10 000-char cap), so the full panel is mounted only while open; this keeps the gist —
|
|
34
|
+
* input, then output or the note explaining its absence — readable through the row's
|
|
35
|
+
* `textContent` for a `page.*` DOM reader that never clicks.
|
|
36
|
+
*
|
|
37
|
+
* @param required - Same as {@link describeToolCallDetail}.
|
|
38
|
+
* @param options - Same as {@link describeToolCallDetail}; `maxChars` defaults to {@link COLLAPSED_DETAIL_MAX_CHARS}.
|
|
39
|
+
* @returns Input (when it has a JSON form) and output or note, joined by a newline — never empty, since a missing output always carries a note.
|
|
40
|
+
* @complexity O(n) in the serialized input plus result length (redaction runs before the cap).
|
|
41
|
+
*/
|
|
42
|
+
export function describeCollapsedToolCall(required, options = {}) {
|
|
43
|
+
const detail = describeToolCallDetail(required, { maxChars: COLLAPSED_DETAIL_MAX_CHARS, ...options });
|
|
44
|
+
return [detail.inputText, detail.outputText ?? detail.noteKey].filter((part) => part !== null).join('\n');
|
|
45
|
+
}
|
|
46
|
+
function missingResultNote({ runStreaming, runSucceeded }) {
|
|
47
|
+
if (runStreaming)
|
|
48
|
+
return 'Waiting for the result or answer…';
|
|
49
|
+
return runSucceeded ? 'No result was recorded for this call.' : 'The run ended before this call returned a result.';
|
|
50
|
+
}
|
|
51
|
+
/** A string input is shown as given (re-indented only when it is itself a JSON object/array); anything else is pretty JSON, or `null` when it has no JSON form (circular, a function). */
|
|
52
|
+
function serializeInput(input) {
|
|
53
|
+
if (input === null || input === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof input === 'string')
|
|
56
|
+
return formatToolOutputForDisplay({ text: input });
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(input, null, 2) ?? null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function truncate(text, max) {
|
|
65
|
+
return text.length <= max ? text : text.slice(0, max - 1) + '…';
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=tool-call-detail.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AgentEvent } from './events.js';
|
|
2
|
+
import type { ChatMessage } from './messages.js';
|
|
3
|
+
type UsageEvent = Extract<AgentEvent, {
|
|
4
|
+
kind: 'usage';
|
|
5
|
+
}>;
|
|
6
|
+
/** A turn's reported usage plus the conversation's prefix total. `segments` is present only when the
|
|
7
|
+
* message holds more than one reply (a mid-run message started a new one): one entry per reply, in
|
|
8
|
+
* order, each with its own prefix total, so every reply can show its own "Done" line. */
|
|
9
|
+
export type ChatUsageSummary = UsageEvent & {
|
|
10
|
+
totalCostUsd?: number;
|
|
11
|
+
segments?: readonly (ChatUsageSummary | undefined)[];
|
|
12
|
+
};
|
|
13
|
+
type SessionCost = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
chargedUsd: number;
|
|
16
|
+
};
|
|
17
|
+
/** The ext event a message sent mid-run arrives as (the daemon's `user_message` run event). The agent
|
|
18
|
+
* answers it as a new reply inside the same run, so it ends the reply before it. */
|
|
19
|
+
export declare const MID_RUN_USER_MESSAGE_EVENT_NAME = "user_message";
|
|
20
|
+
/** Whether this event is a mid-run user message, i.e. the boundary between two replies of one run. */
|
|
21
|
+
export declare function isReplyBoundary({ event }: {
|
|
22
|
+
event: AgentEvent;
|
|
23
|
+
}, _optional?: {}): boolean;
|
|
24
|
+
/** Split one assistant message's events into replies at each mid-run user message. A usage event
|
|
25
|
+
* that lands after a boundary but before the next reply produced anything is the interrupted
|
|
26
|
+
* reply's own result (the daemon notes it can arrive after the interrupt ack), so it stays with
|
|
27
|
+
* the reply before. Event order is otherwise preserved. O(n) for n events.
|
|
28
|
+
* @example replySegments({ events }).length // 1 + number of mid-run user messages */
|
|
29
|
+
export declare function replySegments({ events }: {
|
|
30
|
+
events: readonly AgentEvent[] | undefined;
|
|
31
|
+
}, _optional?: {}): AgentEvent[][];
|
|
32
|
+
/** Sum reported segment usage for a turn, including aborted segments and recovered attempts.
|
|
33
|
+
* Optional projection state is updated to reconcile session snapshots across turns.
|
|
34
|
+
* Missing fields stay absent; money is never inferred. O(n) time for n events.
|
|
35
|
+
* @example turnUsage({ events: message.events }) */
|
|
36
|
+
export declare function turnUsage({ events }: {
|
|
37
|
+
events: readonly AgentEvent[] | undefined;
|
|
38
|
+
}, { sessions, agentId }?: {
|
|
39
|
+
sessions?: Map<string, SessionCost>;
|
|
40
|
+
agentId?: string;
|
|
41
|
+
}): UsageEvent | undefined;
|
|
42
|
+
/** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
|
|
43
|
+
* chats alike. A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
|
|
44
|
+
* and m messages; no per-row transcript scans or I/O.
|
|
45
|
+
* @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
|
|
46
|
+
export declare function conversationUsage({ messages }: {
|
|
47
|
+
messages: readonly ChatMessage[];
|
|
48
|
+
}, _optional?: {}): Map<string, ChatUsageSummary>;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=turn-usage.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** The ext event a message sent mid-run arrives as (the daemon's `user_message` run event). The agent
|
|
2
|
+
* answers it as a new reply inside the same run, so it ends the reply before it. */
|
|
3
|
+
export const MID_RUN_USER_MESSAGE_EVENT_NAME = 'user_message';
|
|
4
|
+
/** Whether this event is a mid-run user message, i.e. the boundary between two replies of one run. */
|
|
5
|
+
export function isReplyBoundary({ event }, _optional = {}) {
|
|
6
|
+
return event.kind === 'ext' && event.name === MID_RUN_USER_MESSAGE_EVENT_NAME;
|
|
7
|
+
}
|
|
8
|
+
/** Split one assistant message's events into replies at each mid-run user message. A usage event
|
|
9
|
+
* that lands after a boundary but before the next reply produced anything is the interrupted
|
|
10
|
+
* reply's own result (the daemon notes it can arrive after the interrupt ack), so it stays with
|
|
11
|
+
* the reply before. Event order is otherwise preserved. O(n) for n events.
|
|
12
|
+
* @example replySegments({ events }).length // 1 + number of mid-run user messages */
|
|
13
|
+
export function replySegments({ events }, _optional = {}) {
|
|
14
|
+
const segments = [[]];
|
|
15
|
+
let started = false;
|
|
16
|
+
for (const event of events ?? []) {
|
|
17
|
+
if (isReplyBoundary({ event })) {
|
|
18
|
+
segments.push([]);
|
|
19
|
+
started = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const target = event.kind === 'usage' && !started && segments.length > 1 ? segments.length - 2 : segments.length - 1;
|
|
23
|
+
if (event.kind !== 'usage' && event.kind !== 'status' && event.kind !== 'raw')
|
|
24
|
+
started = true;
|
|
25
|
+
segments[target].push(event);
|
|
26
|
+
}
|
|
27
|
+
return segments;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve one charge against this agent/session's last snapshot. State is local to an
|
|
30
|
+
* ordered transcript projection; revisiting a session keeps its baseline. A lower snapshot
|
|
31
|
+
* starts a new counter epoch (some CLIs reset their counter on resume). O(1) map access. */
|
|
32
|
+
function usageCost({ event, agentId, sessions }, _optional = {}) {
|
|
33
|
+
const sessionId = event.costSessionId;
|
|
34
|
+
if (!sessionId)
|
|
35
|
+
return validCost(event.costUsd);
|
|
36
|
+
const key = JSON.stringify([agentId, sessionId]);
|
|
37
|
+
const chargedUsd = sessions.get(key)?.chargedUsd ?? 0;
|
|
38
|
+
const cumulative = validCost(event.cumulativeCostUsd);
|
|
39
|
+
const cost = cumulative === undefined ? validCost(event.costUsd)
|
|
40
|
+
: cumulative < chargedUsd ? cumulative : cumulative - chargedUsd;
|
|
41
|
+
// Interim interrupted-segment charges are already paid; later snapshots subtract them.
|
|
42
|
+
// After a counter reset the new snapshot, rather than the old epoch's total, is the baseline.
|
|
43
|
+
sessions.set(key, { sessionId, chargedUsd: cumulative ?? chargedUsd + (cost ?? 0) });
|
|
44
|
+
return cost;
|
|
45
|
+
}
|
|
46
|
+
/** Missing or malformed costs stay unknown; never infer charges from tokens. */
|
|
47
|
+
function validCost(value) {
|
|
48
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Sum reported segment usage for a turn, including aborted segments and recovered attempts.
|
|
51
|
+
* Optional projection state is updated to reconcile session snapshots across turns.
|
|
52
|
+
* Missing fields stay absent; money is never inferred. O(n) time for n events.
|
|
53
|
+
* @example turnUsage({ events: message.events }) */
|
|
54
|
+
export function turnUsage({ events }, { sessions = new Map(), agentId = '' } = {}) {
|
|
55
|
+
let total;
|
|
56
|
+
for (const event of events ?? []) {
|
|
57
|
+
if (event.kind !== 'usage')
|
|
58
|
+
continue;
|
|
59
|
+
total ??= { kind: 'usage' };
|
|
60
|
+
for (const field of ['inputTokens', 'outputTokens', 'durationMs']) {
|
|
61
|
+
const value = event[field];
|
|
62
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
63
|
+
total[field] = (total[field] ?? 0) + value;
|
|
64
|
+
}
|
|
65
|
+
const cost = usageCost({ event, agentId, sessions });
|
|
66
|
+
if (cost !== undefined)
|
|
67
|
+
total.costUsd = (total.costUsd ?? 0) + cost;
|
|
68
|
+
}
|
|
69
|
+
return total;
|
|
70
|
+
}
|
|
71
|
+
/** Add one reply's usage into a message total; fields absent from both stay absent. O(1). */
|
|
72
|
+
function addUsage({ total, usage }, _optional = {}) {
|
|
73
|
+
if (!usage)
|
|
74
|
+
return total;
|
|
75
|
+
const next = { ...(total ?? { kind: 'usage' }) };
|
|
76
|
+
for (const field of ['inputTokens', 'outputTokens', 'durationMs', 'costUsd']) {
|
|
77
|
+
const value = usage[field];
|
|
78
|
+
if (value !== undefined)
|
|
79
|
+
next[field] = (next[field] ?? 0) + value;
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
/** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
|
|
84
|
+
* chats alike. A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
|
|
85
|
+
* and m messages; no per-row transcript scans or I/O.
|
|
86
|
+
* @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
|
|
87
|
+
export function conversationUsage({ messages }, _optional = {}) {
|
|
88
|
+
const sessions = new Map();
|
|
89
|
+
const summaries = new Map();
|
|
90
|
+
let totalCostUsd;
|
|
91
|
+
for (const message of messages) {
|
|
92
|
+
if (message.role !== 'assistant')
|
|
93
|
+
continue;
|
|
94
|
+
// Replies run in event order through one session map, so their sum equals the whole-message charge.
|
|
95
|
+
const segments = [];
|
|
96
|
+
let usage;
|
|
97
|
+
for (const events of replySegments({ events: message.events })) {
|
|
98
|
+
const segment = turnUsage({ events }, { sessions, agentId: message.agentId ?? '' });
|
|
99
|
+
usage = addUsage({ total: usage, usage: segment });
|
|
100
|
+
if (segment?.costUsd !== undefined)
|
|
101
|
+
totalCostUsd = (totalCostUsd ?? 0) + segment.costUsd;
|
|
102
|
+
segments.push(segment && { ...segment, ...(totalCostUsd === undefined ? {} : { totalCostUsd }) });
|
|
103
|
+
}
|
|
104
|
+
if (!usage)
|
|
105
|
+
continue;
|
|
106
|
+
summaries.set(message.id, {
|
|
107
|
+
...usage,
|
|
108
|
+
...(totalCostUsd === undefined ? {} : { totalCostUsd }),
|
|
109
|
+
...(segments.length > 1 ? { segments } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return summaries;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=turn-usage.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Format a known turn charge, and its conversation prefix total only when one is passed; round only
|
|
2
|
+
* for display. The total is the chat's running total as of that turn, so an earlier turn's line never
|
|
3
|
+
* changes when later turns run (owner, 2026-10-10: "Done · 16s · $0.39 · total $3.95").
|
|
4
|
+
* @example formatUsageCost({ costUsd: 0.445, totalCostUsd: 2.5 }) // '$0.45 · total $2.50'
|
|
5
|
+
* @example formatUsageCost({ costUsd: 0.445 }) // '$0.45' */
|
|
6
|
+
export declare function formatUsageCost({ costUsd, totalCostUsd }: {
|
|
7
|
+
costUsd: number;
|
|
8
|
+
totalCostUsd?: number | undefined;
|
|
9
|
+
}, _optional?: {}): string;
|
|
10
|
+
//# sourceMappingURL=usage-format.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Round reported USD upward to cents after clearing arithmetic noise at 1e-8 USD precision.
|
|
2
|
+
* Keep the minimum cent for any positive reported charge, even below the noise precision. */
|
|
3
|
+
function dollars(usd) {
|
|
4
|
+
const cents = usd > 0 ? Math.max(1, Math.ceil(Math.round(usd * 1e8) / 1e6)) : 0;
|
|
5
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
6
|
+
}
|
|
7
|
+
/** Format a known turn charge, and its conversation prefix total only when one is passed; round only
|
|
8
|
+
* for display. The total is the chat's running total as of that turn, so an earlier turn's line never
|
|
9
|
+
* changes when later turns run (owner, 2026-10-10: "Done · 16s · $0.39 · total $3.95").
|
|
10
|
+
* @example formatUsageCost({ costUsd: 0.445, totalCostUsd: 2.5 }) // '$0.45 · total $2.50'
|
|
11
|
+
* @example formatUsageCost({ costUsd: 0.445 }) // '$0.45' */
|
|
12
|
+
export function formatUsageCost({ costUsd, totalCostUsd }, _optional = {}) {
|
|
13
|
+
return totalCostUsd === undefined ? dollars(costUsd) : `${dollars(costUsd)} · total ${dollars(totalCostUsd)}`;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=usage-format.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies. */
|
|
2
|
+
export declare const CHAT_BUBBLE_STYLE = ".jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:grid;place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy=\"true\"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed=\"true\"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}";
|
|
3
|
+
export interface LazyChatLoaderArgs {
|
|
4
|
+
readonly button: HTMLButtonElement;
|
|
5
|
+
readonly load: (args: Record<string, never>) => Promise<void>;
|
|
6
|
+
readonly open: (args: {
|
|
7
|
+
focus?: boolean;
|
|
8
|
+
}) => void;
|
|
9
|
+
readonly resume: boolean;
|
|
10
|
+
readonly deepLink: boolean;
|
|
11
|
+
readonly preload?: boolean;
|
|
12
|
+
readonly loadingLabel: string;
|
|
13
|
+
readonly retryLabel: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Activates a host-rendered button without importing chat, React, icons or storage.
|
|
17
|
+
* One in-flight load serves repeated activation; failures remain retryable. Native button
|
|
18
|
+
* keyboard activation also emits click; preventDefault avoids a second toggle after keydown.
|
|
19
|
+
*/
|
|
20
|
+
export declare function installLazyChatLoader(args: LazyChatLoaderArgs, _options?: {}): {
|
|
21
|
+
activate: ({ open, focus }?: {
|
|
22
|
+
open?: boolean;
|
|
23
|
+
focus?: boolean;
|
|
24
|
+
}, _options?: {}) => Promise<void>;
|
|
25
|
+
dispose(_input?: Record<string, never>, _options?: {}): void;
|
|
26
|
+
};
|
|
27
|
+
/** Loads CSS before a self-mounting classic script; failed elements are removed for retry. */
|
|
28
|
+
export declare function loadChatAssets({ document, scriptUrl, styleUrl, mount }: {
|
|
29
|
+
document: Document;
|
|
30
|
+
scriptUrl: string;
|
|
31
|
+
styleUrl: string;
|
|
32
|
+
mount?: HTMLElement;
|
|
33
|
+
}, _options?: {}): Promise<void>;
|
|
34
|
+
//# sourceMappingURL=lazy-loader.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies. */
|
|
2
|
+
export const CHAT_BUBBLE_STYLE = '.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:grid;place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy="true"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed="true"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}';
|
|
3
|
+
/**
|
|
4
|
+
* Activates a host-rendered button without importing chat, React, icons or storage.
|
|
5
|
+
* One in-flight load serves repeated activation; failures remain retryable. Native button
|
|
6
|
+
* keyboard activation also emits click; preventDefault avoids a second toggle after keydown.
|
|
7
|
+
*/
|
|
8
|
+
export function installLazyChatLoader(args, _options = {}) {
|
|
9
|
+
let pending = null;
|
|
10
|
+
let ready = false;
|
|
11
|
+
let disposed = false;
|
|
12
|
+
let shouldOpen = false;
|
|
13
|
+
let shouldFocus = false;
|
|
14
|
+
const initialContent = args.button.innerHTML;
|
|
15
|
+
const label = args.button.getAttribute('aria-label') ?? '';
|
|
16
|
+
const activate = async ({ open = true, focus = open } = {}, _options = {}) => {
|
|
17
|
+
if (disposed)
|
|
18
|
+
return;
|
|
19
|
+
if (!pending) {
|
|
20
|
+
shouldOpen = false;
|
|
21
|
+
shouldFocus = false;
|
|
22
|
+
}
|
|
23
|
+
shouldOpen ||= open;
|
|
24
|
+
shouldFocus ||= focus;
|
|
25
|
+
if (pending)
|
|
26
|
+
return pending;
|
|
27
|
+
args.button.setAttribute('aria-busy', 'true');
|
|
28
|
+
args.button.setAttribute('aria-label', args.loadingLabel);
|
|
29
|
+
args.button.textContent = args.loadingLabel;
|
|
30
|
+
delete args.button.dataset.loadFailed;
|
|
31
|
+
// Defer through a promise so a synchronous host load failure follows the same retry path.
|
|
32
|
+
pending = Promise.resolve().then(() => { if (!ready)
|
|
33
|
+
return args.load({}); }).then(() => {
|
|
34
|
+
if (disposed)
|
|
35
|
+
return;
|
|
36
|
+
// Asset readiness survives an open failure; retry must never evaluate the script twice.
|
|
37
|
+
ready = true;
|
|
38
|
+
if (shouldOpen)
|
|
39
|
+
args.open({ focus: shouldFocus });
|
|
40
|
+
args.button.setAttribute('aria-label', label);
|
|
41
|
+
args.button.innerHTML = initialContent;
|
|
42
|
+
args.button.hidden = true;
|
|
43
|
+
}).catch(() => {
|
|
44
|
+
if (!disposed) {
|
|
45
|
+
args.button.hidden = false;
|
|
46
|
+
args.button.dataset.loadFailed = 'true';
|
|
47
|
+
args.button.setAttribute('aria-label', args.retryLabel);
|
|
48
|
+
args.button.textContent = args.retryLabel;
|
|
49
|
+
}
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
pending = null;
|
|
52
|
+
args.button.removeAttribute('aria-busy');
|
|
53
|
+
});
|
|
54
|
+
return pending;
|
|
55
|
+
};
|
|
56
|
+
const click = () => { void activate({}); };
|
|
57
|
+
const keydown = (event) => {
|
|
58
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
59
|
+
return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
void activate({});
|
|
62
|
+
};
|
|
63
|
+
args.button.addEventListener('click', click);
|
|
64
|
+
args.button.addEventListener('keydown', keydown);
|
|
65
|
+
args.button.dataset.loaderReady = 'true';
|
|
66
|
+
if (args.resume || args.deepLink)
|
|
67
|
+
void activate({ focus: args.deepLink });
|
|
68
|
+
else if (args.preload)
|
|
69
|
+
void activate({ open: false });
|
|
70
|
+
return {
|
|
71
|
+
activate,
|
|
72
|
+
dispose(_input = {}, _options = {}) {
|
|
73
|
+
disposed = true;
|
|
74
|
+
args.button.removeEventListener('click', click);
|
|
75
|
+
args.button.removeEventListener('keydown', keydown);
|
|
76
|
+
args.button.setAttribute('aria-label', label);
|
|
77
|
+
args.button.innerHTML = initialContent;
|
|
78
|
+
args.button.removeAttribute('aria-busy');
|
|
79
|
+
delete args.button.dataset.loadFailed;
|
|
80
|
+
delete args.button.dataset.loaderReady;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Loads CSS before a self-mounting classic script; failed elements are removed for retry. */
|
|
85
|
+
export async function loadChatAssets({ document, scriptUrl, styleUrl, mount }, _options = {}) {
|
|
86
|
+
const stylesheet = document.createElement('link');
|
|
87
|
+
stylesheet.rel = 'stylesheet';
|
|
88
|
+
stylesheet.href = styleUrl;
|
|
89
|
+
const script = document.createElement('script');
|
|
90
|
+
script.src = scriptUrl;
|
|
91
|
+
const loaded = (element) => new Promise((resolve, reject) => {
|
|
92
|
+
element.onload = () => resolve();
|
|
93
|
+
element.onerror = () => reject(new Error('Chat assets could not be loaded'));
|
|
94
|
+
document.head.appendChild(element);
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
await loaded(stylesheet);
|
|
98
|
+
await loaded(script);
|
|
99
|
+
// A classic script's load event proves evaluation, not React commit or a successful mount.
|
|
100
|
+
// Optional for legacy hosts; lazy visitor hosts supply their root as the readiness boundary.
|
|
101
|
+
if (mount && mount.dataset.chatMounted !== 'true') {
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
const ready = () => { cleanup(); resolve(); };
|
|
104
|
+
const timeout = setTimeout(() => { cleanup(); reject(new Error('Chat widget did not mount')); }, 10_000);
|
|
105
|
+
const cleanup = () => { clearTimeout(timeout); mount.removeEventListener('jini-chat-mounted', ready); };
|
|
106
|
+
mount.addEventListener('jini-chat-mounted', ready);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
stylesheet.remove();
|
|
112
|
+
script.remove();
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=lazy-loader.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Shares the panel-kit trap across embeds. Escape respects a child popup's preventDefault;
|
|
4
|
+
* Closing restores the mounted launcher, including after a lazy-loader handoff.
|
|
5
|
+
* A changed resetKey focuses the replacement composer after the host remounts the pane.
|
|
6
|
+
* @complexity O(focusable controls) per Tab; O(1) listeners and retained state.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useEmbedChatDialog({ open, onClose, panelRef, launcherRef }: {
|
|
9
|
+
open: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
panelRef: RefObject<HTMLElement | null>;
|
|
12
|
+
launcherRef: RefObject<HTMLElement | null>;
|
|
13
|
+
}, { resetKey }?: {
|
|
14
|
+
resetKey?: number | undefined;
|
|
15
|
+
}): void;
|
|
16
|
+
/** Reusable state/ref wiring for the packaged embed. O(1) besides the shared trap's Tab scan. */
|
|
17
|
+
export declare function useEmbedChatSurface({ initialOpen }: {
|
|
18
|
+
initialOpen: boolean;
|
|
19
|
+
}, { resetKey }?: {
|
|
20
|
+
resetKey?: number | undefined;
|
|
21
|
+
}): {
|
|
22
|
+
open: boolean;
|
|
23
|
+
toggle: () => void;
|
|
24
|
+
panelRef: RefObject<HTMLDivElement | null>;
|
|
25
|
+
launcherRef: RefObject<HTMLDivElement | null>;
|
|
26
|
+
panelId: string;
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=dialog.hooks.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useFocusTrap } from '@jini-ai/ui/panel-kit';
|
|
3
|
+
/**
|
|
4
|
+
* Shares the panel-kit trap across embeds. Escape respects a child popup's preventDefault;
|
|
5
|
+
* Closing restores the mounted launcher, including after a lazy-loader handoff.
|
|
6
|
+
* A changed resetKey focuses the replacement composer after the host remounts the pane.
|
|
7
|
+
* @complexity O(focusable controls) per Tab; O(1) listeners and retained state.
|
|
8
|
+
*/
|
|
9
|
+
export function useEmbedChatDialog({ open, onClose, panelRef, launcherRef }, { resetKey } = {}) {
|
|
10
|
+
const wasOpen = useRef(open);
|
|
11
|
+
const previousResetKey = useRef(resetKey);
|
|
12
|
+
useFocusTrap({ containerRef: panelRef }, { active: open });
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
if (open && previousResetKey.current !== resetKey) {
|
|
15
|
+
panelRef.current?.querySelector('textarea:not(:disabled)')?.focus();
|
|
16
|
+
}
|
|
17
|
+
previousResetKey.current = resetKey;
|
|
18
|
+
}, [open, resetKey, panelRef]);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (wasOpen.current && !open) {
|
|
21
|
+
const launcher = launcherRef.current;
|
|
22
|
+
(launcher?.querySelector('button') ?? launcher)?.focus();
|
|
23
|
+
}
|
|
24
|
+
wasOpen.current = open;
|
|
25
|
+
}, [open, launcherRef]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open)
|
|
28
|
+
return;
|
|
29
|
+
const panel = panelRef.current;
|
|
30
|
+
const view = panel?.ownerDocument.defaultView;
|
|
31
|
+
if (!panel || !view)
|
|
32
|
+
return;
|
|
33
|
+
const onKeyDown = (event) => {
|
|
34
|
+
if (event.key === 'Escape' && !event.defaultPrevented) {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
onClose();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
view.addEventListener('keydown', onKeyDown);
|
|
40
|
+
return () => view.removeEventListener('keydown', onKeyDown);
|
|
41
|
+
}, [open, onClose, panelRef]);
|
|
42
|
+
}
|
|
43
|
+
/** Reusable state/ref wiring for the packaged embed. O(1) besides the shared trap's Tab scan. */
|
|
44
|
+
export function useEmbedChatSurface({ initialOpen }, { resetKey } = {}) {
|
|
45
|
+
const [open, setOpen] = useState(initialOpen);
|
|
46
|
+
const panelRef = useRef(null);
|
|
47
|
+
const launcherRef = useRef(null);
|
|
48
|
+
const panelId = useId();
|
|
49
|
+
const close = useCallback(() => setOpen(false), []);
|
|
50
|
+
const toggle = useCallback(() => setOpen(current => !current), []);
|
|
51
|
+
useEmbedChatDialog({ open, onClose: close, panelRef, launcherRef }, { resetKey });
|
|
52
|
+
return { open, toggle, panelRef, launcherRef, panelId };
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=dialog.hooks.js.map
|
|
@@ -2,6 +2,7 @@ import type { ChatTransport } from '../../core/transport.js';
|
|
|
2
2
|
import type { ChatPaneAgent } from '../../react/chat-pane.js';
|
|
3
3
|
import type { BrowserSessionStore } from '../browser/session-store.js';
|
|
4
4
|
import type { PageActionPort } from '../../core/browser/page-actions.js';
|
|
5
|
+
export { useEmbedChatDialog } from './dialog.hooks.js';
|
|
5
6
|
import type { EmbedHeaderCopy, EscapeKeyPort } from './header.js';
|
|
6
7
|
export { EmbedChatHeader } from './header.js';
|
|
7
8
|
export type { EmbedChatHeaderArgs, EmbedHeaderCopy, EscapeKeyPort } from './header.js';
|
|
@@ -7,6 +7,8 @@ import { ChatFab } from '../../react/components/ChatFab.js';
|
|
|
7
7
|
import { JiniChatProvider } from '../../react/components/JiniChatProvider.js';
|
|
8
8
|
import { extractPageActions, splitPageActions, isQueuedPageAction } from '../../core/browser/page-actions.js';
|
|
9
9
|
import { EmbedChatHeader } from './header.js';
|
|
10
|
+
import { useEmbedChatSurface } from './dialog.hooks.js';
|
|
11
|
+
export { useEmbedChatDialog } from './dialog.hooks.js';
|
|
10
12
|
export { EmbedChatHeader } from './header.js';
|
|
11
13
|
const shellStyles = `
|
|
12
14
|
.jini-chat-embed__panel { position:fixed; right:var(--jini-embed-right,24px); bottom:var(--jini-embed-bottom,88px); width:min(var(--jini-embed-width,440px),calc(100vw - 32px)); height:min(var(--jini-embed-height,620px),calc(100dvh - 120px)); z-index:var(--jini-embed-z-index,1000); display:flex; flex-direction:column; }
|
|
@@ -25,9 +27,9 @@ const shellStyles = `
|
|
|
25
27
|
export function EmbedChatWidget(args) {
|
|
26
28
|
const { agent, copy, transport, session, pageActions, directiveEventName, keyboard, scheduleFrame } = args;
|
|
27
29
|
const [initial] = useState(() => session.load({}));
|
|
28
|
-
const [open, setOpen] = useState(initial.open);
|
|
29
|
-
const [messages, setMessages] = useState(initial.messages);
|
|
30
30
|
const [paneKey, setPaneKey] = useState(0);
|
|
31
|
+
const { open, toggle, panelRef, launcherRef, panelId } = useEmbedChatSurface({ initialOpen: initial.open }, { resetKey: paneKey });
|
|
32
|
+
const [messages, setMessages] = useState(initial.messages);
|
|
31
33
|
const [pending, setPending] = useState(null);
|
|
32
34
|
const processed = useRef(new Set(initial.messages.map(message => message.id)));
|
|
33
35
|
useEffect(() => { session.save({ state: { open, messages } }); }, [session, open, messages]);
|
|
@@ -76,7 +78,7 @@ export function EmbedChatWidget(args) {
|
|
|
76
78
|
else
|
|
77
79
|
setPending(proposal);
|
|
78
80
|
}, [directiveEventName, pageActions, go]);
|
|
79
|
-
return _jsxs("div", { className: "jini-chat-embed", children: [_jsx("style", { children: shellStyles }), _jsx(ChatFab, { open: open, onToggle:
|
|
81
|
+
return _jsxs("div", { className: "jini-chat-embed", children: [_jsx("style", { children: shellStyles }), _jsx("div", { ref: launcherRef, tabIndex: -1, children: _jsx(ChatFab, { open: open, onToggle: toggle, "aria-label": copy.fabLabel, label: copy.fabLabel, "aria-controls": panelId, "aria-haspopup": "dialog" }) }), _jsx(JiniChatProvider, { transport: transport, children: _jsxs("div", { id: panelId, className: "jini-chat-embed__panel", hidden: !open, ref: panelRef, role: "dialog", "aria-modal": "true", "aria-label": copy.title, children: [_jsx(ChatPane, { transport: transport, agents: [agent], initialSelection: { agentId: agent.id }, initialMessages: messages, title: copy.title, placeholder: copy.placeholder, variant: "embed", header: _jsx(EmbedChatHeader, { title: copy.title, hasMessages: messages.length > 0, onReset: reset, copy: copy, keyboard: keyboard }), onMessagesChange: onMessagesChange }, paneKey), pending ? _jsxs("div", { className: "jini-chat-embed__proposal", children: [_jsx("span", { children: copy.proposalLabel({ title: pending.navigate.target.title }) }), _jsx("button", { type: "button", onClick: () => { setPending(null); go(pending, messages); }, children: copy.proposalGo })] }) : null] }) })] });
|
|
80
82
|
}
|
|
81
83
|
/** Mounts into a host-provided node and returns lifecycle cleanup. O(1) plus React rendering. */
|
|
82
84
|
export function mountEmbedChat({ element, widget }) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
export interface EmbedHeaderCopy {
|
|
2
3
|
readonly eyebrow: string;
|
|
3
4
|
readonly newThread: string;
|
|
@@ -15,7 +16,8 @@ export interface EmbedChatHeaderArgs {
|
|
|
15
16
|
readonly onReset: () => void;
|
|
16
17
|
readonly copy: EmbedHeaderCopy;
|
|
17
18
|
readonly keyboard: EscapeKeyPort;
|
|
19
|
+
readonly closeAction?: ReactNode;
|
|
18
20
|
}
|
|
19
21
|
/** Two-step reset with safe initial focus, Cancel and host-wide Escape. O(1) state and listeners. */
|
|
20
|
-
export declare function EmbedChatHeader({ title, hasMessages, onReset, copy, keyboard }: EmbedChatHeaderArgs): import("react").JSX.Element;
|
|
22
|
+
export declare function EmbedChatHeader({ title, hasMessages, onReset, copy, keyboard, closeAction }: EmbedChatHeaderArgs): import("react").JSX.Element;
|
|
21
23
|
//# sourceMappingURL=header.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EmbedChatHeaderArgs } from './header.js';
|
|
2
|
+
/** Confirm discarding messages or a live draft; Cancel/Escape restore the reset button.
|
|
3
|
+
* @complexity O(pane controls + draft length) per request; O(1) retained state.
|
|
4
|
+
*/
|
|
5
|
+
export declare function useEmbedChatHeader({ hasMessages, onReset, keyboard }: Pick<EmbedChatHeaderArgs, 'hasMessages' | 'onReset' | 'keyboard'>, _options?: {}): {
|
|
6
|
+
confirming: boolean;
|
|
7
|
+
cancel: () => void;
|
|
8
|
+
headerRef: import("react").RefObject<HTMLDivElement | null>;
|
|
9
|
+
resetButtonRef: import("react").RefObject<HTMLButtonElement | null>;
|
|
10
|
+
requestReset: () => void;
|
|
11
|
+
confirmReset: () => void;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=header.hooks.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
/** Confirm discarding messages or a live draft; Cancel/Escape restore the reset button.
|
|
3
|
+
* @complexity O(pane controls + draft length) per request; O(1) retained state.
|
|
4
|
+
*/
|
|
5
|
+
export function useEmbedChatHeader({ hasMessages, onReset, keyboard }, _options = {}) {
|
|
6
|
+
const [confirming, setConfirming] = useState(false);
|
|
7
|
+
const headerRef = useRef(null);
|
|
8
|
+
const resetButtonRef = useRef(null);
|
|
9
|
+
const wasConfirming = useRef(false);
|
|
10
|
+
const cancel = () => setConfirming(false);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
if (wasConfirming.current && !confirming)
|
|
13
|
+
resetButtonRef.current?.focus();
|
|
14
|
+
wasConfirming.current = confirming;
|
|
15
|
+
}, [confirming]);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!confirming)
|
|
18
|
+
return;
|
|
19
|
+
return keyboard.subscribe({ onEscape: () => setConfirming(false) });
|
|
20
|
+
}, [confirming, keyboard]);
|
|
21
|
+
return { confirming, cancel, headerRef, resetButtonRef,
|
|
22
|
+
requestReset: () => {
|
|
23
|
+
// The pane owns the draft; inspect its live composer rather than keeping a second copy.
|
|
24
|
+
const composer = headerRef.current?.closest('.jini-chat-pane')?.querySelector('.jini-composer-input');
|
|
25
|
+
if (hasMessages || composer?.value)
|
|
26
|
+
setConfirming(true);
|
|
27
|
+
else
|
|
28
|
+
onReset();
|
|
29
|
+
},
|
|
30
|
+
confirmReset: () => { setConfirming(false); onReset(); }, };
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=header.hooks.js.map
|
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useEmbedChatHeader } from './header.hooks.js';
|
|
3
3
|
/** Two-step reset with safe initial focus, Cancel and host-wide Escape. O(1) state and listeners. */
|
|
4
|
-
export function EmbedChatHeader({ title, hasMessages, onReset, copy, keyboard }) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
if (!confirming)
|
|
8
|
-
return;
|
|
9
|
-
return keyboard.subscribe({ onEscape: () => setConfirming(false) });
|
|
10
|
-
}, [confirming, keyboard]);
|
|
11
|
-
return _jsxs("div", { className: "jini-chat-pane__header", children: [_jsxs("div", { className: "jini-chat-pane__heading", children: [_jsx("span", { className: "jini-chat-pane__eyebrow", children: copy.eyebrow }), _jsx("h2", { className: "jini-chat-pane__title", children: title })] }), confirming ? _jsxs("div", { className: "jini-chat-embed__reset-confirm", children: [_jsx("button", { type: "button", className: "jini-chat-embed__reset-cancel", autoFocus: true, onClick: () => setConfirming(false), children: copy.cancel }), _jsx("button", { type: "button", className: "jini-chat-pane__new-thread", onClick: () => { setConfirming(false); onReset(); }, children: copy.discard })] }) : _jsx("button", { type: "button", className: "jini-chat-pane__new-thread", onClick: () => {
|
|
12
|
-
if (hasMessages)
|
|
13
|
-
setConfirming(true);
|
|
14
|
-
else
|
|
15
|
-
onReset();
|
|
16
|
-
}, children: copy.newThread })] });
|
|
4
|
+
export function EmbedChatHeader({ title, hasMessages, onReset, copy, keyboard, closeAction }) {
|
|
5
|
+
const { confirming, cancel, requestReset, confirmReset, headerRef, resetButtonRef } = useEmbedChatHeader({ hasMessages, onReset, keyboard });
|
|
6
|
+
return _jsxs("div", { className: "jini-chat-pane__header", ref: headerRef, children: [_jsxs("div", { className: "jini-chat-pane__heading", children: [copy.eyebrow && _jsx("span", { className: "jini-chat-pane__eyebrow", children: copy.eyebrow }), _jsx("h2", { className: "jini-chat-pane__title", children: title })] }), confirming ? _jsxs("div", { className: "jini-chat-embed__reset-confirm", children: [_jsx("button", { type: "button", className: "jini-chat-embed__reset-cancel", autoFocus: true, onClick: cancel, children: copy.cancel }), _jsx("button", { type: "button", className: "jini-chat-pane__new-thread", onClick: confirmReset, children: copy.discard })] }) : _jsx("button", { type: "button", className: "jini-chat-pane__new-thread", ref: resetButtonRef, onClick: requestReset, children: copy.newThread }), closeAction] });
|
|
17
7
|
}
|
|
18
8
|
//# sourceMappingURL=header.js.map
|