@jini-ai/chat 0.5.3 → 0.5.4
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 +7 -0
- package/dist/core/composer-history.d.ts +54 -0
- package/dist/core/composer-history.js +57 -0
- package/dist/core/durable-projection.d.ts +28 -0
- package/dist/core/durable-projection.js +71 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -0
- package/dist/core/transport.d.ts +4 -0
- package/dist/react/components/Composer.js +6 -0
- package/dist/react/components/MessageRow.js +24 -11
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +5 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +9 -6
- package/dist/react/features/chat-pane/types.d.ts +5 -0
- package/dist/react/hooks/composer-history-storage.d.ts +8 -0
- package/dist/react/hooks/composer-history-storage.js +40 -0
- package/dist/react/hooks/useComposer.d.ts +10 -1
- package/dist/react/hooks/useComposer.js +14 -2
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
- package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
- package/dist/react/hooks/useConversation.js +6 -2
- package/dist/react/hooks/useRunStream.js +12 -2
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +1 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 0.5.4 — 2026-10-06
|
|
2
|
+
|
|
3
|
+
- Prompt history in the composer: ↑ with the caret on the first line recalls earlier sent messages and ↓ on the last line walks forward; the first ↑ stashes the current draft (empty or not) and ↓ past the newest entry (or Escape) restores it exactly. Recalled entries are never mutated; consecutive duplicates collapse. New pure `createComposerHistoryState` / `transitionComposerHistory` / `composerHistoryKeyAction` / `mergeComposerHistory` / `normalizeComposerHistory` (core) and `createBrowserComposerHistoryStorage` (react, a `ComposerHistoryStoragePort` default). `useComposer`/`ChatPane` take `historyScope`, `historyStorage` and `historyMessages`.
|
|
4
|
+
- Durable runs: new `recoveredRunEvents` (core) projects a recovered run's saved events so a continued answer completes the same message; a run notice shows only on a real resume.
|
|
5
|
+
- Message attachments: the transport sends each message's own attachment refs, and attachment chips keep their kind across reloads.
|
|
6
|
+
- Requires `@jini-ai/protocol` ^0.4.1.
|
|
7
|
+
|
|
1
8
|
## 0.5.3 — 2026-10-06
|
|
2
9
|
|
|
3
10
|
- New `createLastConversationStore({ scope }, { storage? })` (react): remembers one conversation id per caller identity under a versioned localStorage key (`jini.chat.last-conversation.v1.<scope>`); corrupt or foreign entries read as nothing remembered. Selection stays host-owned.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Text-only prompt recall. Browsing owns a snapshot so incoming turns cannot move its cursor. */
|
|
2
|
+
export interface ComposerHistoryState {
|
|
3
|
+
entries: readonly string[];
|
|
4
|
+
index: number | null;
|
|
5
|
+
draft: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface ComposerHistoryStoragePort {
|
|
8
|
+
read: (required: {
|
|
9
|
+
scope: string;
|
|
10
|
+
}, optional: {}) => Promise<readonly string[]>;
|
|
11
|
+
write: (required: {
|
|
12
|
+
scope: string;
|
|
13
|
+
entries: readonly string[];
|
|
14
|
+
}, optional: {}) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface ComposerHistoryKeyInput {
|
|
17
|
+
key: string;
|
|
18
|
+
text: string;
|
|
19
|
+
selectionStart: number;
|
|
20
|
+
selectionEnd: number;
|
|
21
|
+
shiftKey?: boolean;
|
|
22
|
+
ctrlKey?: boolean;
|
|
23
|
+
altKey?: boolean;
|
|
24
|
+
metaKey?: boolean;
|
|
25
|
+
isComposing?: boolean;
|
|
26
|
+
popupOpen?: boolean;
|
|
27
|
+
/** A DOM host can refine logical newline boundaries with measured soft-wrap boundaries. */
|
|
28
|
+
firstLine?: boolean;
|
|
29
|
+
lastLine?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare function normalizeComposerHistory({ entries }: {
|
|
32
|
+
entries: readonly string[];
|
|
33
|
+
}, { cap }?: {
|
|
34
|
+
cap?: number;
|
|
35
|
+
}): string[];
|
|
36
|
+
export declare function mergeComposerHistory({ recent, current }: {
|
|
37
|
+
recent: readonly string[];
|
|
38
|
+
current: readonly string[];
|
|
39
|
+
}, { cap }?: {
|
|
40
|
+
cap?: number;
|
|
41
|
+
}): string[];
|
|
42
|
+
export declare function createComposerHistoryState(_required: {}, _optional?: {}): ComposerHistoryState;
|
|
43
|
+
export declare function composerHistoryKeyAction(input: ComposerHistoryKeyInput, _optional?: {}): 'previous' | 'next' | 'restore' | null;
|
|
44
|
+
export declare function transitionComposerHistory({ state, action, text, entries }: {
|
|
45
|
+
state: ComposerHistoryState;
|
|
46
|
+
action: 'previous' | 'next' | 'restore' | 'edit' | 'reset';
|
|
47
|
+
text: string;
|
|
48
|
+
entries: readonly string[];
|
|
49
|
+
}, _optional?: {}): {
|
|
50
|
+
state: ComposerHistoryState;
|
|
51
|
+
text: string;
|
|
52
|
+
handled: boolean;
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=composer-history.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function normalizeComposerHistory({ entries }, { cap = 100 } = {}) {
|
|
2
|
+
const result = [];
|
|
3
|
+
for (const text of entries) {
|
|
4
|
+
// Blank/attachment-only turns are not useful prompts; meaningful whitespace stays exact.
|
|
5
|
+
if (text.trim() && text !== result[result.length - 1])
|
|
6
|
+
result.push(text);
|
|
7
|
+
}
|
|
8
|
+
return result.slice(-Math.max(1, Math.floor(cap)));
|
|
9
|
+
}
|
|
10
|
+
export function mergeComposerHistory({ recent, current }, { cap = 100 } = {}) {
|
|
11
|
+
const left = normalizeComposerHistory({ entries: recent }, { cap });
|
|
12
|
+
const right = normalizeComposerHistory({ entries: current }, { cap });
|
|
13
|
+
// A mounted conversation is usually already the recent list's suffix. Do not append its
|
|
14
|
+
// entire transcript again on every streamed render; preserve nonconsecutive repetitions.
|
|
15
|
+
let overlap = Math.min(left.length, right.length);
|
|
16
|
+
while (overlap > 0 && !right.slice(0, overlap).every((text, i) => text === left[left.length - overlap + i]))
|
|
17
|
+
overlap -= 1;
|
|
18
|
+
return normalizeComposerHistory({ entries: [...left, ...right.slice(overlap)] }, { cap });
|
|
19
|
+
}
|
|
20
|
+
export function createComposerHistoryState(_required, _optional = {}) {
|
|
21
|
+
return { entries: [], index: null, draft: null };
|
|
22
|
+
}
|
|
23
|
+
export function composerHistoryKeyAction(input, _optional = {}) {
|
|
24
|
+
if (input.shiftKey || input.ctrlKey || input.altKey || input.metaKey || input.isComposing || input.popupOpen)
|
|
25
|
+
return null;
|
|
26
|
+
if (input.key === 'Escape')
|
|
27
|
+
return 'restore';
|
|
28
|
+
const start = Math.min(input.selectionStart, input.selectionEnd);
|
|
29
|
+
const end = Math.max(input.selectionStart, input.selectionEnd);
|
|
30
|
+
if (input.text.slice(start, end).includes('\n'))
|
|
31
|
+
return null;
|
|
32
|
+
const firstLine = !input.text.slice(0, end).includes('\n') && input.firstLine !== false;
|
|
33
|
+
const lastLine = !input.text.slice(start).includes('\n') && input.lastLine !== false;
|
|
34
|
+
if (input.key === 'ArrowUp' && firstLine)
|
|
35
|
+
return 'previous';
|
|
36
|
+
if (input.key === 'ArrowDown' && lastLine)
|
|
37
|
+
return 'next';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function transitionComposerHistory({ state, action, text, entries }, _optional = {}) {
|
|
41
|
+
if (action === 'edit' || action === 'reset') {
|
|
42
|
+
return { state: createComposerHistoryState({}), text, handled: false };
|
|
43
|
+
}
|
|
44
|
+
if (state.index === null) {
|
|
45
|
+
if (action !== 'previous' || entries.length === 0)
|
|
46
|
+
return { state, text, handled: false };
|
|
47
|
+
const snapshot = [...entries];
|
|
48
|
+
const index = snapshot.length - 1;
|
|
49
|
+
return { state: { entries: snapshot, index, draft: text }, text: snapshot[index], handled: true };
|
|
50
|
+
}
|
|
51
|
+
if (action === 'restore' || (action === 'next' && state.index === state.entries.length - 1)) {
|
|
52
|
+
return { state: createComposerHistoryState({}), text: state.draft ?? '', handled: true };
|
|
53
|
+
}
|
|
54
|
+
const index = action === 'previous' ? Math.max(0, state.index - 1) : state.index + 1;
|
|
55
|
+
return { state: { ...state, index }, text: state.entries[index], handled: true };
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=composer-history.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AgentEvent } from './events.js';
|
|
2
|
+
import type { ChatMessage } from './messages.js';
|
|
3
|
+
/** Saved checkpoints can coalesce deltas and a completion may replay only a prefix. Keep the
|
|
4
|
+
* saved projection, merge identifiable events, and append only text beyond its saved prefix. */
|
|
5
|
+
export declare function mergeRunEvents({ saved, incoming }: {
|
|
6
|
+
saved?: readonly AgentEvent[] | undefined;
|
|
7
|
+
incoming: readonly AgentEvent[];
|
|
8
|
+
}, _optional?: {}): AgentEvent[];
|
|
9
|
+
/** A recovered attempt stays visibly in progress until new text or tool work supersedes its marker. */
|
|
10
|
+
export declare function continuingRunNotice({ events, active }: {
|
|
11
|
+
events: readonly AgentEvent[] | undefined;
|
|
12
|
+
active: boolean;
|
|
13
|
+
}, _optional?: {}): string | null;
|
|
14
|
+
export declare function preserveAssistantContent({ saved, incoming }: {
|
|
15
|
+
saved: string;
|
|
16
|
+
incoming: string;
|
|
17
|
+
}, _optional?: {}): string;
|
|
18
|
+
/** Terminal failure belongs to the message. Legacy resend instructions are display-only;
|
|
19
|
+
* changing this copy must never execute or reopen an old terminal run. */
|
|
20
|
+
export declare function terminalMessageNotice({ message }: {
|
|
21
|
+
message: ChatMessage;
|
|
22
|
+
}, _optional?: {}): string | null;
|
|
23
|
+
/** The first answer has no earlier segment to divide, even if startup was retried. Only saved
|
|
24
|
+
* visible text can justify a Continued divider; the recovery marker still records the attempt. */
|
|
25
|
+
export declare function recoveredRunEvents({ saved }: {
|
|
26
|
+
saved: readonly AgentEvent[];
|
|
27
|
+
}, _optional?: {}): AgentEvent[];
|
|
28
|
+
//# sourceMappingURL=durable-projection.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function identity(event) {
|
|
2
|
+
const wire = event;
|
|
3
|
+
if (wire.eventId !== undefined)
|
|
4
|
+
return `event:${wire.eventId}`;
|
|
5
|
+
if (wire.cursor !== undefined)
|
|
6
|
+
return `cursor:${wire.cursor}`;
|
|
7
|
+
if (event.kind === 'tool_use')
|
|
8
|
+
return `use:${event.id}`;
|
|
9
|
+
if (event.kind === 'tool_result')
|
|
10
|
+
return `result:${event.toolUseId}`;
|
|
11
|
+
return JSON.stringify(event);
|
|
12
|
+
}
|
|
13
|
+
function textOf(events) {
|
|
14
|
+
return events.filter((event) => event.kind === 'text').map((event) => event.text).join('');
|
|
15
|
+
}
|
|
16
|
+
/** Saved checkpoints can coalesce deltas and a completion may replay only a prefix. Keep the
|
|
17
|
+
* saved projection, merge identifiable events, and append only text beyond its saved prefix. */
|
|
18
|
+
export function mergeRunEvents({ saved, incoming }, _optional = {}) {
|
|
19
|
+
const previous = saved ?? [];
|
|
20
|
+
const result = [...previous];
|
|
21
|
+
const keys = new Set(previous.map(identity));
|
|
22
|
+
const before = textOf(previous);
|
|
23
|
+
const after = textOf(incoming);
|
|
24
|
+
let skip = after.startsWith(before) ? before.length : after.length;
|
|
25
|
+
for (const event of incoming) {
|
|
26
|
+
if (event.kind === 'text') {
|
|
27
|
+
const text = event.text.slice(skip);
|
|
28
|
+
skip = Math.max(0, skip - event.text.length);
|
|
29
|
+
if (text)
|
|
30
|
+
result.push({ ...event, text });
|
|
31
|
+
}
|
|
32
|
+
else if (!keys.has(identity(event))) {
|
|
33
|
+
keys.add(identity(event));
|
|
34
|
+
result.push(event);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
/** A recovered attempt stays visibly in progress until new text or tool work supersedes its marker. */
|
|
40
|
+
export function continuingRunNotice({ events, active }, _optional = {}) {
|
|
41
|
+
if (!active)
|
|
42
|
+
return null;
|
|
43
|
+
const list = events ?? [];
|
|
44
|
+
const marker = list.findLastIndex((event) => event.kind === 'status' && event.code === 'run_recovering');
|
|
45
|
+
if (marker < 0)
|
|
46
|
+
return null;
|
|
47
|
+
const progressed = list.slice(marker + 1).some((event) => event.kind === 'text' || event.kind === 'tool_use' || event.kind === 'tool_result');
|
|
48
|
+
return progressed ? null : 'Continuing…';
|
|
49
|
+
}
|
|
50
|
+
export function preserveAssistantContent({ saved, incoming }, _optional = {}) {
|
|
51
|
+
return incoming.length >= saved.length ? incoming : saved;
|
|
52
|
+
}
|
|
53
|
+
/** Terminal failure belongs to the message. Legacy resend instructions are display-only;
|
|
54
|
+
* changing this copy must never execute or reopen an old terminal run. */
|
|
55
|
+
export function terminalMessageNotice({ message }, _optional = {}) {
|
|
56
|
+
if (message.runStatus !== 'failed' && message.runStatus !== 'canceled')
|
|
57
|
+
return null;
|
|
58
|
+
const notice = message.events?.filter((event) => event.kind === 'status' &&
|
|
59
|
+
(event.code === 'run_terminal' || /failed|stopped|restarted|could not|not logged in|cancell?ed/i.test(event.label))).at(-1);
|
|
60
|
+
if (notice?.kind === 'status')
|
|
61
|
+
return [notice.label, notice.detail].filter(Boolean).join(' ').replace(/Send your message again to retry\.?/gi, '').trim();
|
|
62
|
+
return message.runStatus === 'failed' ? 'This turn failed.' : 'Stopped.';
|
|
63
|
+
}
|
|
64
|
+
/** The first answer has no earlier segment to divide, even if startup was retried. Only saved
|
|
65
|
+
* visible text can justify a Continued divider; the recovery marker still records the attempt. */
|
|
66
|
+
export function recoveredRunEvents({ saved }, _optional = {}) {
|
|
67
|
+
const hasAnswer = saved.some(event => event.kind === 'text' && event.text.trim().length > 0);
|
|
68
|
+
return [...saved, ...(hasAnswer ? [{ kind: 'text', text: '\n\n---\n\nContinued\n\n' }] : []),
|
|
69
|
+
{ kind: 'status', code: 'run_recovering', label: 'Continuing…' }];
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=durable-projection.js.map
|
package/dist/core/index.d.ts
CHANGED
|
@@ -42,4 +42,7 @@ export * from './agentic/index.js';
|
|
|
42
42
|
* module, and a host binds it to its own authentication.
|
|
43
43
|
*/
|
|
44
44
|
export * from './persistence/index.js';
|
|
45
|
+
export { recoveredRunEvents } from './durable-projection.js';
|
|
46
|
+
export { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from './composer-history.js';
|
|
47
|
+
export type { ComposerHistoryKeyInput, ComposerHistoryState, ComposerHistoryStoragePort } from './composer-history.js';
|
|
45
48
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/core/index.js
CHANGED
|
@@ -42,4 +42,6 @@ export * from './agentic/index.js';
|
|
|
42
42
|
* module, and a host binds it to its own authentication.
|
|
43
43
|
*/
|
|
44
44
|
export * from './persistence/index.js';
|
|
45
|
+
export { recoveredRunEvents } from './durable-projection.js';
|
|
46
|
+
export { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from './composer-history.js';
|
|
45
47
|
//# sourceMappingURL=index.js.map
|
package/dist/core/transport.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ import type { ChatAttachment, ChatMessage, ChatRunStatus } from './messages.js';
|
|
|
30
30
|
* via `extends`.
|
|
31
31
|
*/
|
|
32
32
|
export interface RunHandlers {
|
|
33
|
+
/** Authoritative saved projection; may advance an attempt without ending its message. */
|
|
34
|
+
onCheckpoint?: (message: ChatMessage) => void;
|
|
33
35
|
/** Fired once per renderable unit of agent output. */
|
|
34
36
|
onEvent: (ev: AgentEvent) => void;
|
|
35
37
|
/**
|
|
@@ -50,6 +52,8 @@ export interface RunHandlers {
|
|
|
50
52
|
*/
|
|
51
53
|
export type RunContext = Record<string, unknown>;
|
|
52
54
|
export interface StartRunInput {
|
|
55
|
+
/** The logical assistant message identity, allocated before acceptance. */
|
|
56
|
+
assistantMessageId?: string;
|
|
53
57
|
history: ChatMessage[];
|
|
54
58
|
agentId?: string;
|
|
55
59
|
conversationId?: string | null;
|
|
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
15
15
|
import { useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
16
16
|
import { RemixIcon } from '@jini-ai/ui';
|
|
17
17
|
import { useT } from '../hooks/context.js';
|
|
18
|
+
import { useComposerHistoryKeys } from '../hooks/useComposerHistoryKeys.hooks.js';
|
|
18
19
|
import { AttachmentTray } from './AttachmentTray.js';
|
|
19
20
|
import { ComposerDiscoveryMenu, ComposerSlashMenu } from './ComposerDiscovery.js';
|
|
20
21
|
import { appendComposerDiscovery, composerDiscoveryMenuPosition, composerSlashMenuPosition, filterComposerDiscovery, parseComposerSlashQuery, replaceComposerSlashTrigger, resolveComposerSlashInvocation, resolveComposerSlashKeyAction, } from './composer-discovery.js';
|
|
@@ -103,6 +104,9 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
|
|
|
103
104
|
* point the shrinking list is its own feedback.
|
|
104
105
|
*/
|
|
105
106
|
const showSlashFilterHint = slashQuery !== null && slashQuery.command === '' && slashQuery.argument === null;
|
|
107
|
+
const handleHistoryKey = useComposerHistoryKeys({ composer, textareaRef }, {
|
|
108
|
+
popupOpen: slashOpen || discoveryMenuOpen || workdirOpen || composer.mention.open,
|
|
109
|
+
});
|
|
106
110
|
function restoreComposerFocus() {
|
|
107
111
|
textareaRef.current?.focus();
|
|
108
112
|
}
|
|
@@ -286,6 +290,8 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
|
|
|
286
290
|
return;
|
|
287
291
|
}
|
|
288
292
|
}
|
|
293
|
+
if (handleHistoryKey(event))
|
|
294
|
+
return;
|
|
289
295
|
if (event.key !== 'Enter' || event.shiftKey)
|
|
290
296
|
return;
|
|
291
297
|
event.preventDefault();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
|
|
2
3
|
/**
|
|
3
4
|
* @module MessageRow
|
|
4
5
|
*
|
|
@@ -120,6 +121,23 @@ function UsageSummary({ usage }) {
|
|
|
120
121
|
return null;
|
|
121
122
|
return (_jsxs("div", { className: "jini-message-usage", children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", parts.join(' · ')] }));
|
|
122
123
|
}
|
|
124
|
+
function messageBlocks({ visibleContent, message, rows }, _optional = {}) {
|
|
125
|
+
// Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
|
|
126
|
+
// whole string and trims, so the surviving text no longer lines up with the per-run offsets the
|
|
127
|
+
// event walk produces, and an artifact block could legitimately span a tool call. Rather than
|
|
128
|
+
// reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
|
|
129
|
+
// messages are dominated by the artifact panel anyway, so the ordering matters least there.
|
|
130
|
+
if (visibleContent !== message.content)
|
|
131
|
+
return null;
|
|
132
|
+
return interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) });
|
|
133
|
+
}
|
|
134
|
+
function messageAwaitingAnswer({ runStreaming, runInProgress, events }, _optional = {}) {
|
|
135
|
+
return runStreaming === true && runInProgress && isAwaitingAnswer({ events });
|
|
136
|
+
}
|
|
137
|
+
function AssistantMessageFooter({ message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent }, _optional = {}) {
|
|
138
|
+
const t = useT();
|
|
139
|
+
return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) }) : null] });
|
|
140
|
+
}
|
|
123
141
|
/**
|
|
124
142
|
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
125
143
|
* action rows below. Delegates the actual clipboard write (and its
|
|
@@ -147,20 +165,14 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
147
165
|
if (message.role === 'user') {
|
|
148
166
|
return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx("button", { type: "button", className: "jini-message-attachment-chip", onClick: () => setOpenAttachment(a), title: t('Open {name}', { name: a.name }), "aria-label": t('Open {name}', { name: a.name }), children: renderAttachment ? renderAttachment(a) : a.name }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
|
|
149
167
|
}
|
|
168
|
+
const terminalNotice = terminalMessageNotice({ message }, {});
|
|
150
169
|
const visibleContent = stripArtifact({ content: message.content });
|
|
151
170
|
const segments = splitOnQuestionForms({ input: visibleContent });
|
|
152
171
|
const usageEvent = message.events?.filter((ev) => ev.kind === 'usage').pop();
|
|
153
|
-
|
|
154
|
-
// whole string and trims, so the surviving text no longer lines up with the per-run offsets the
|
|
155
|
-
// event walk produces, and an artifact block could legitimately span a tool call. Rather than
|
|
156
|
-
// reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
|
|
157
|
-
// messages are dominated by the artifact panel anyway, so the ordering matters least there.
|
|
158
|
-
const blocks = visibleContent === message.content
|
|
159
|
-
? interleaveMessageBlocks({ events: message.events, content: message.content, rows: timeline.rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) })
|
|
160
|
-
: null;
|
|
172
|
+
const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows }, {});
|
|
161
173
|
const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
|
|
162
174
|
const runInProgress = isRunInProgress(message.runStatus);
|
|
163
|
-
const awaitingAnswer = runStreaming
|
|
175
|
+
const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, {});
|
|
164
176
|
const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
165
177
|
const renderExtGroup = (group) => {
|
|
166
178
|
const renderer = getExtEventRenderer({ name: group.name });
|
|
@@ -197,7 +209,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
197
209
|
return null;
|
|
198
210
|
return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
|
|
199
211
|
})
|
|
200
|
-
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })),
|
|
212
|
+
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent })] }));
|
|
201
213
|
}
|
|
202
214
|
/**
|
|
203
215
|
* The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
|
|
@@ -205,7 +217,8 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
205
217
|
* Kept a separate component so its one-second tick re-renders only this line, not the whole row.
|
|
206
218
|
*/
|
|
207
219
|
function RunActivityLine({ events, active }) {
|
|
208
|
-
const
|
|
220
|
+
const activityLabel = useRunActivity({ events: events, active: active });
|
|
221
|
+
const label = continuingRunNotice({ events, active }, {}) ?? activityLabel;
|
|
209
222
|
if (label === null)
|
|
210
223
|
return null;
|
|
211
224
|
return (_jsx("div", { className: "jini-message-pending jini-run-activity", "aria-live": "off", children: label }));
|
|
@@ -6,5 +6,5 @@ import type { ChatPaneProps } from '../types.js';
|
|
|
6
6
|
* @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
|
|
7
7
|
* @overallScore 100/100
|
|
8
8
|
*/
|
|
9
|
-
export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
|
|
9
|
+
export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
|
|
10
10
|
//# sourceMappingURL=ChatPane.d.ts.map
|
|
@@ -73,8 +73,8 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
|
|
|
73
73
|
}
|
|
74
74
|
/** The pane's stack of mutually-independent error/status banners — each condition is its own
|
|
75
75
|
* source of truth, so they are rendered as siblings rather than folded into one derived state. */
|
|
76
|
-
function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange,
|
|
77
|
-
return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null,
|
|
76
|
+
function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, t, }) {
|
|
77
|
+
return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
|
|
78
78
|
}
|
|
79
79
|
function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
|
|
80
80
|
if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
|
|
@@ -178,7 +178,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
|
|
|
178
178
|
* @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
|
|
179
179
|
* @overallScore 100/100
|
|
180
180
|
*/
|
|
181
|
-
export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
|
|
181
|
+
export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
|
|
182
182
|
const t = useT();
|
|
183
183
|
const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: placeholder, placeholders: placeholders });
|
|
184
184
|
const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
|
|
@@ -201,6 +201,8 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
|
|
|
201
201
|
onSelectionChange,
|
|
202
202
|
runContext,
|
|
203
203
|
initialDraft,
|
|
204
|
+
composerHistoryScope,
|
|
205
|
+
composerHistoryStorage,
|
|
204
206
|
validateAttachments,
|
|
205
207
|
uploadAttachments,
|
|
206
208
|
onActivityChange,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ChatPaneSendBlocker } from '../rules.js';
|
|
2
2
|
import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
|
|
3
3
|
import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
|
|
4
|
+
import type { ComposerHistoryStoragePort } from '../../../../core/composer-history.js';
|
|
4
5
|
import type { ChatTransport } from '@jini-ai/chat';
|
|
5
6
|
import { type UseComposerResult } from '../../../hooks/useComposer.js';
|
|
6
7
|
import { type UseConversationResult } from '../../../hooks/useConversation.js';
|
|
@@ -15,6 +16,8 @@ export interface UseChatPaneOptions {
|
|
|
15
16
|
onSelectionChange?: (selection: ChatPaneAgentSelection) => void;
|
|
16
17
|
runContext?: ChatPaneRunContext;
|
|
17
18
|
initialDraft?: string;
|
|
19
|
+
composerHistoryScope?: string;
|
|
20
|
+
composerHistoryStorage?: ComposerHistoryStoragePort;
|
|
18
21
|
/** Forwarded verbatim to `useComposer`; see `validateAttachments` there for the contract. */
|
|
19
22
|
validateAttachments?: (attachments: readonly ChatAttachment[]) => Promise<readonly ChatAttachment[]>;
|
|
20
23
|
uploadAttachments?: (files: File[], options?: ChatPaneAttachmentUploadOptions) => Promise<ChatAttachment[]>;
|
|
@@ -114,12 +114,6 @@ export function useChatPane(options) {
|
|
|
114
114
|
const requestedSelection = options.selection ?? internalSelection;
|
|
115
115
|
const selection = useMemo(() => resolveChatPaneSelection({ agents: options.agents, requested: requestedSelection }), [options.agents, requestedSelection]);
|
|
116
116
|
const selectedAgent = options.agents.find((agent) => agent.id === selection.agentId);
|
|
117
|
-
const composer = useComposer(definedProps({ source: {
|
|
118
|
-
initialDraft: options.initialDraft,
|
|
119
|
-
initialAgent: selection,
|
|
120
|
-
conversationId: options.conversationId,
|
|
121
|
-
validateAttachments: options.validateAttachments,
|
|
122
|
-
} }));
|
|
123
117
|
const conversation = useConversation(definedProps({ source: {
|
|
124
118
|
transport: options.transport,
|
|
125
119
|
initialMessages: options.initialMessages,
|
|
@@ -128,6 +122,15 @@ export function useChatPane(options) {
|
|
|
128
122
|
// absent), so only the falsy "no agent selected" case should omit the key.
|
|
129
123
|
agentId: selection.agentId || undefined,
|
|
130
124
|
} }));
|
|
125
|
+
const composer = useComposer(definedProps({ source: {
|
|
126
|
+
initialDraft: options.initialDraft,
|
|
127
|
+
historyMessages: conversation.messages,
|
|
128
|
+
historyScope: options.composerHistoryScope,
|
|
129
|
+
historyStorage: options.composerHistoryStorage,
|
|
130
|
+
initialAgent: selection,
|
|
131
|
+
conversationId: options.conversationId,
|
|
132
|
+
validateAttachments: options.validateAttachments,
|
|
133
|
+
} }));
|
|
131
134
|
const activity = resolveChatPaneActivity(selectedAgent, conversation);
|
|
132
135
|
useEffect(() => {
|
|
133
136
|
options.onActivityChange?.(activity);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
|
2
2
|
import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
|
|
3
3
|
import type { ComposerSlots } from '../../slots.js';
|
|
4
|
+
import type { ComposerHistoryStoragePort } from '../../../core/composer-history.js';
|
|
4
5
|
import type { ChatTransport, RunContext } from '@jini-ai/chat';
|
|
5
6
|
export interface ChatPaneAgentOption {
|
|
6
7
|
id: string;
|
|
@@ -172,6 +173,10 @@ export interface ChatPaneProps {
|
|
|
172
173
|
/** Passed straight through to the runtime picker. */
|
|
173
174
|
onByokModelChange?: (model: string) => void;
|
|
174
175
|
initialDraft?: string;
|
|
176
|
+
/** Principal-scoped recent prompt recall; defaults to the origin-local default scope. */
|
|
177
|
+
composerHistoryScope?: string;
|
|
178
|
+
/** Optional storage effects; omitted uses bounded browser storage with memory fallback. */
|
|
179
|
+
composerHistoryStorage?: ComposerHistoryStoragePort;
|
|
175
180
|
/**
|
|
176
181
|
* Confirms which previously staged attachments still exist, so a draft restored after a reload
|
|
177
182
|
* never shows a chip for a file the backend has since garbage-collected — that looks intact and
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ComposerHistoryStoragePort } from '../../core/composer-history.js';
|
|
2
|
+
/** Browser default; private/embedded hosts may replace this port without changing recall. */
|
|
3
|
+
export declare function createBrowserComposerHistoryStorage(_required: {}, { storage, prefix }?: {
|
|
4
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'>;
|
|
5
|
+
prefix?: string;
|
|
6
|
+
}): ComposerHistoryStoragePort;
|
|
7
|
+
export declare const browserComposerHistoryStorage: ComposerHistoryStoragePort;
|
|
8
|
+
//# sourceMappingURL=composer-history-storage.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { normalizeComposerHistory } from '../../core/composer-history.js';
|
|
2
|
+
/** Browser default; private/embedded hosts may replace this port without changing recall. */
|
|
3
|
+
export function createBrowserComposerHistoryStorage(_required, { storage, prefix = 'jini:composer-history:v1:' } = {}) {
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
function resolveStorage() {
|
|
6
|
+
// Accessing localStorage itself can throw (sandboxed embeds/private browsing).
|
|
7
|
+
return storage ?? globalThis.localStorage;
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
async read({ scope }, _optional = {}) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = resolveStorage()?.getItem(prefix + encodeURIComponent(scope));
|
|
13
|
+
if (raw) {
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (Array.isArray(parsed)) {
|
|
16
|
+
const entries = normalizeComposerHistory({ entries: parsed.filter((value) => typeof value === 'string') });
|
|
17
|
+
memory.set(scope, entries);
|
|
18
|
+
return entries;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Storage denial/corruption must not disable draft editing or in-memory recall.
|
|
24
|
+
}
|
|
25
|
+
return memory.get(scope) ?? [];
|
|
26
|
+
},
|
|
27
|
+
async write({ scope, entries }, _optional = {}) {
|
|
28
|
+
const normalized = normalizeComposerHistory({ entries });
|
|
29
|
+
memory.set(scope, normalized);
|
|
30
|
+
try {
|
|
31
|
+
resolveStorage()?.setItem(prefix + encodeURIComponent(scope), JSON.stringify(normalized));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Quota/denial falls back to the same scoped in-memory list.
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export const browserComposerHistoryStorage = createBrowserComposerHistoryStorage({});
|
|
40
|
+
//# sourceMappingURL=composer-history-storage.js.map
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { ChatAttachment } from '../../core/index.js';
|
|
1
|
+
import type { ChatAttachment, ChatMessage } from '../../core/index.js';
|
|
2
|
+
import type { ComposerHistoryStoragePort } from '../../core/composer-history.js';
|
|
3
|
+
import { type ComposerHistoryController } from './useComposerHistory.hooks.js';
|
|
2
4
|
import type { AgentSelection, ComposerSlots, MentionResult, ProjectContextValue } from '../slots.js';
|
|
3
5
|
export interface ComposerDraftPersistence {
|
|
4
6
|
read: () => string | null;
|
|
@@ -6,6 +8,11 @@ export interface ComposerDraftPersistence {
|
|
|
6
8
|
}
|
|
7
9
|
export interface UseComposerOptions {
|
|
8
10
|
initialDraft?: string;
|
|
11
|
+
/** This principal's current transcript; only user-role text participates in recall. */
|
|
12
|
+
historyMessages?: readonly ChatMessage[];
|
|
13
|
+
/** Scopes recent prompts across conversations. Hosts with multiple users must supply an id. */
|
|
14
|
+
historyScope?: string;
|
|
15
|
+
historyStorage?: ComposerHistoryStoragePort;
|
|
9
16
|
initialAgent?: AgentSelection;
|
|
10
17
|
project?: ProjectContextValue;
|
|
11
18
|
composerSlots?: ComposerSlots;
|
|
@@ -42,6 +49,8 @@ export interface MentionPopoverState {
|
|
|
42
49
|
}
|
|
43
50
|
export interface UseComposerResult {
|
|
44
51
|
draft: string;
|
|
52
|
+
/** Optional for compatibility with host-created composer controllers. */
|
|
53
|
+
history?: ComposerHistoryController;
|
|
45
54
|
setDraft: (next: string) => void;
|
|
46
55
|
attachments: ChatAttachment[];
|
|
47
56
|
/** Uploads via `project.uploadFiles` (when supplied) and stages the results; no-ops (with a rejected promise) when no upload port is wired. */
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* the wrong thread, so they are cleared instead.
|
|
29
29
|
*/
|
|
30
30
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
31
|
+
import { useComposerHistory } from './useComposerHistory.hooks.js';
|
|
31
32
|
import { readCachedAttachments, readCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
|
|
32
33
|
import { cacheAttachmentPreviewSource } from './attachment-preview-cache.js';
|
|
33
34
|
const EMPTY_MENTION = { open: false, query: '', results: [] };
|
|
@@ -37,11 +38,21 @@ export function useComposer(options = {}) {
|
|
|
37
38
|
const [attachments, setAttachments] = useState([]);
|
|
38
39
|
const [agent, setAgent] = useState(options.initialAgent);
|
|
39
40
|
const [mention, setMention] = useState(EMPTY_MENTION);
|
|
40
|
-
const
|
|
41
|
+
const commitDraft = useCallback((next) => {
|
|
41
42
|
setDraftState(next);
|
|
42
43
|
persistence?.write(next);
|
|
43
44
|
writeCachedDraft({ conversationId: conversationId, draft: next });
|
|
44
45
|
}, [persistence, conversationId]);
|
|
46
|
+
const history = useComposerHistory({ setRecalledDraft: setDraftState }, {
|
|
47
|
+
...(options.historyMessages === undefined ? {} : { messages: options.historyMessages }),
|
|
48
|
+
...(options.historyScope === undefined ? {} : { scope: options.historyScope }),
|
|
49
|
+
...(options.historyStorage === undefined ? {} : { storage: options.historyStorage }),
|
|
50
|
+
...(conversationId === undefined ? {} : { conversationId }),
|
|
51
|
+
});
|
|
52
|
+
const setDraft = useCallback((next) => {
|
|
53
|
+
history.edit({});
|
|
54
|
+
commitDraft(next);
|
|
55
|
+
}, [commitDraft, history.edit]);
|
|
45
56
|
// Lets the conversation-change effect below read the live draft without taking `draft` as a
|
|
46
57
|
// dependency, which would re-run it on every keystroke.
|
|
47
58
|
const draftRef = useRef(draft);
|
|
@@ -195,6 +206,7 @@ export function useComposer(options = {}) {
|
|
|
195
206
|
const canSubmit = draft.trim().length > 0 || attachments.length > 0;
|
|
196
207
|
return useMemo(() => ({
|
|
197
208
|
draft,
|
|
209
|
+
history,
|
|
198
210
|
setDraft,
|
|
199
211
|
attachments,
|
|
200
212
|
addAttachments,
|
|
@@ -209,6 +221,6 @@ export function useComposer(options = {}) {
|
|
|
209
221
|
selectMention,
|
|
210
222
|
canSubmit,
|
|
211
223
|
reset,
|
|
212
|
-
}), [draft, setDraft, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset]);
|
|
224
|
+
}), [draft, history, setDraft, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset]);
|
|
213
225
|
}
|
|
214
226
|
//# sourceMappingURL=useComposer.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ChatMessage } from '../../core/messages.js';
|
|
2
|
+
import { type ComposerHistoryKeyInput, type ComposerHistoryStoragePort } from '../../core/composer-history.js';
|
|
3
|
+
export interface ComposerHistoryController {
|
|
4
|
+
navigate: (input: ComposerHistoryKeyInput, optional?: {}) => boolean;
|
|
5
|
+
edit: (required: {}, optional?: {}) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function useComposerHistory({ setRecalledDraft }: {
|
|
8
|
+
setRecalledDraft: (text: string) => void;
|
|
9
|
+
}, { messages, scope, storage, conversationId }?: {
|
|
10
|
+
messages?: readonly ChatMessage[];
|
|
11
|
+
scope?: string;
|
|
12
|
+
storage?: ComposerHistoryStoragePort;
|
|
13
|
+
conversationId?: string | null;
|
|
14
|
+
}): ComposerHistoryController;
|
|
15
|
+
//# sourceMappingURL=useComposerHistory.hooks.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from '../../core/composer-history.js';
|
|
3
|
+
import { browserComposerHistoryStorage } from './composer-history-storage.js';
|
|
4
|
+
const EMPTY_MESSAGES = [];
|
|
5
|
+
export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESSAGES, scope = 'default', storage = browserComposerHistoryStorage, conversationId } = {}) {
|
|
6
|
+
const browseRef = useRef(createComposerHistoryState({}));
|
|
7
|
+
const [recent, setRecent] = useState([]);
|
|
8
|
+
const hydratedPortRef = useRef(null);
|
|
9
|
+
const [hydratedScope, setHydratedScope] = useState(null);
|
|
10
|
+
const pendingRef = useRef([]);
|
|
11
|
+
const conversationRef = useRef(conversationId);
|
|
12
|
+
const scopeRef = useRef(scope);
|
|
13
|
+
const seenRef = useRef(new Set(messages.map((message) => message.id)));
|
|
14
|
+
const writeQueueRef = useRef(Promise.resolve());
|
|
15
|
+
const current = useMemo(() => messages.filter((message) => message.role === 'user').map((message) => message.content), [messages]);
|
|
16
|
+
const entries = mergeComposerHistory({ recent, current });
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
pendingRef.current = [];
|
|
20
|
+
hydratedPortRef.current = null;
|
|
21
|
+
setRecent([]);
|
|
22
|
+
setHydratedScope(null);
|
|
23
|
+
browseRef.current = createComposerHistoryState({});
|
|
24
|
+
// Read after this hook's outstanding writes so an injected async store cannot return stale
|
|
25
|
+
// entries when a scope is revisited. A remounted hook's default storage writes synchronously.
|
|
26
|
+
void writeQueueRef.current.then(() => storage.read({ scope }, {})).catch(() => []).then((loaded) => {
|
|
27
|
+
if (cancelled)
|
|
28
|
+
return;
|
|
29
|
+
// Bootstrap an empty recent store from the already-sent current transcript. This
|
|
30
|
+
// makes opening a new chat useful immediately after installing recall; opening an
|
|
31
|
+
// older transcript later does not reorder an established recent list.
|
|
32
|
+
const seed = loaded.length > 0 ? loaded : current;
|
|
33
|
+
setRecent(normalizeComposerHistory({ entries: [...seed, ...pendingRef.current] }));
|
|
34
|
+
hydratedPortRef.current = storage;
|
|
35
|
+
setHydratedScope(scope);
|
|
36
|
+
});
|
|
37
|
+
return () => { cancelled = true; };
|
|
38
|
+
}, [scope, storage]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const adopted = conversationRef.current == null && conversationId != null && scopeRef.current === scope;
|
|
41
|
+
conversationRef.current = conversationId;
|
|
42
|
+
scopeRef.current = scope;
|
|
43
|
+
if (adopted)
|
|
44
|
+
return;
|
|
45
|
+
browseRef.current = createComposerHistoryState({});
|
|
46
|
+
// A conversation's existing turns are available for navigation, but merely opening old
|
|
47
|
+
// conversations must not reorder the cross-conversation recent list.
|
|
48
|
+
seenRef.current = new Set(messages.map((message) => message.id));
|
|
49
|
+
// messages is deliberately absent: newly appended turns are handled below, not reseeded.
|
|
50
|
+
}, [conversationId, scope]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const added = [];
|
|
53
|
+
for (const message of messages) {
|
|
54
|
+
if (!seenRef.current.has(message.id) && message.role === 'user')
|
|
55
|
+
added.push(message.content);
|
|
56
|
+
seenRef.current.add(message.id);
|
|
57
|
+
}
|
|
58
|
+
if (added.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
pendingRef.current.push(...added);
|
|
61
|
+
pendingRef.current = normalizeComposerHistory({ entries: pendingRef.current });
|
|
62
|
+
setRecent((previous) => normalizeComposerHistory({ entries: [...previous, ...added] }));
|
|
63
|
+
}, [messages]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (hydratedScope !== scope || hydratedPortRef.current !== storage)
|
|
66
|
+
return;
|
|
67
|
+
// Serialize writes; a slow earlier write must not overwrite a later prompt.
|
|
68
|
+
writeQueueRef.current = writeQueueRef.current.then(() => storage.write({ scope, entries: recent }, {})).catch(() => { });
|
|
69
|
+
}, [hydratedScope, recent, scope, storage]);
|
|
70
|
+
const navigate = useCallback((input, _optional = {}) => {
|
|
71
|
+
const action = composerHistoryKeyAction(input);
|
|
72
|
+
if (action === null)
|
|
73
|
+
return false;
|
|
74
|
+
const transition = transitionComposerHistory({ state: browseRef.current, action, text: input.text, entries });
|
|
75
|
+
if (!transition.handled)
|
|
76
|
+
return false;
|
|
77
|
+
browseRef.current = transition.state;
|
|
78
|
+
// Recall is a view, not an edit to the cached working draft. Reload while copying a
|
|
79
|
+
// recalled entry still restores what the operator was writing before they browsed.
|
|
80
|
+
setRecalledDraft(transition.text);
|
|
81
|
+
return true;
|
|
82
|
+
}, [entries, setRecalledDraft]);
|
|
83
|
+
const edit = useCallback((_required, _optional = {}) => {
|
|
84
|
+
// The first edit leaves history. Its text is now the working draft; stored entries and
|
|
85
|
+
// the original browsing snapshot are never edited in place.
|
|
86
|
+
browseRef.current = createComposerHistoryState({});
|
|
87
|
+
}, []);
|
|
88
|
+
return useMemo(() => ({ navigate, edit }), [navigate, edit]);
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=useComposerHistory.hooks.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type KeyboardEvent, type RefObject } from 'react';
|
|
2
|
+
import type { UseComposerResult } from './useComposer.js';
|
|
3
|
+
export declare function useComposerHistoryKeys({ composer, textareaRef }: {
|
|
4
|
+
composer: UseComposerResult;
|
|
5
|
+
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
6
|
+
}, { popupOpen }?: {
|
|
7
|
+
popupOpen?: boolean;
|
|
8
|
+
}): (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;
|
|
9
|
+
//# sourceMappingURL=useComposerHistoryKeys.hooks.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useLayoutEffect, useReducer, useRef } from 'react';
|
|
2
|
+
/** Measures visual lines as well as newlines, so a wrapped paragraph keeps native arrow motion. */
|
|
3
|
+
function visualLineBounds({ textarea }) {
|
|
4
|
+
if (textarea.clientWidth === 0)
|
|
5
|
+
return {};
|
|
6
|
+
const computed = getComputedStyle(textarea);
|
|
7
|
+
const mirror = document.createElement('div');
|
|
8
|
+
for (const property of ['font', 'letter-spacing', 'word-spacing', 'line-height', 'text-indent', 'padding', 'tab-size', 'word-break', 'overflow-wrap', 'direction']) {
|
|
9
|
+
mirror.style.setProperty(property, computed.getPropertyValue(property));
|
|
10
|
+
}
|
|
11
|
+
mirror.style.position = 'fixed';
|
|
12
|
+
mirror.style.visibility = 'hidden';
|
|
13
|
+
mirror.style.pointerEvents = 'none';
|
|
14
|
+
mirror.style.whiteSpace = 'pre-wrap';
|
|
15
|
+
mirror.style.overflowWrap = 'break-word';
|
|
16
|
+
mirror.style.boxSizing = 'border-box';
|
|
17
|
+
mirror.style.width = `${textarea.clientWidth}px`;
|
|
18
|
+
const text = document.createTextNode(textarea.value);
|
|
19
|
+
mirror.append(text, document.createTextNode('\u200b'));
|
|
20
|
+
document.body.append(mirror);
|
|
21
|
+
try {
|
|
22
|
+
function lineAt(offset) {
|
|
23
|
+
const range = document.createRange();
|
|
24
|
+
range.setStart(text, offset);
|
|
25
|
+
range.setEnd(text, offset);
|
|
26
|
+
return range.getBoundingClientRect().top;
|
|
27
|
+
}
|
|
28
|
+
const first = lineAt(0);
|
|
29
|
+
const last = lineAt(text.length);
|
|
30
|
+
// Both selection endpoints must be on the boundary visual line. Selecting across soft
|
|
31
|
+
// wraps should stay native just like selecting across explicit newline characters.
|
|
32
|
+
const start = lineAt(textarea.selectionStart);
|
|
33
|
+
const end = lineAt(textarea.selectionEnd);
|
|
34
|
+
return { firstLine: Math.abs(start - first) < 1 && Math.abs(end - first) < 1, lastLine: Math.abs(start - last) < 1 && Math.abs(end - last) < 1 };
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
mirror.remove();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function useComposerHistoryKeys({ composer, textareaRef }, { popupOpen = false } = {}) {
|
|
41
|
+
const pendingCaretRef = useRef(null);
|
|
42
|
+
// A duplicate history entry can equal the current value, so setDraft may bail out.
|
|
43
|
+
// Still commit the caret request now rather than carrying it into the next real edit.
|
|
44
|
+
const [, requestCaretCommit] = useReducer((revision) => revision + 1, 0);
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
if (!pendingCaretRef.current)
|
|
47
|
+
return;
|
|
48
|
+
const placement = pendingCaretRef.current;
|
|
49
|
+
pendingCaretRef.current = null;
|
|
50
|
+
const textarea = textareaRef.current;
|
|
51
|
+
if (textarea) {
|
|
52
|
+
const offset = placement === 'start' ? 0 : textarea.value.length;
|
|
53
|
+
textarea.setSelectionRange(offset, offset);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return (event) => {
|
|
57
|
+
if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
|
|
58
|
+
return false;
|
|
59
|
+
if (event.defaultPrevented || popupOpen || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
|
60
|
+
return false;
|
|
61
|
+
if (!['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key))
|
|
62
|
+
return false;
|
|
63
|
+
const textarea = event.currentTarget;
|
|
64
|
+
const handled = composer.history?.navigate({
|
|
65
|
+
key: event.key,
|
|
66
|
+
text: textarea.value,
|
|
67
|
+
selectionStart: textarea.selectionStart,
|
|
68
|
+
selectionEnd: textarea.selectionEnd,
|
|
69
|
+
shiftKey: event.shiftKey,
|
|
70
|
+
ctrlKey: event.ctrlKey,
|
|
71
|
+
altKey: event.altKey,
|
|
72
|
+
metaKey: event.metaKey,
|
|
73
|
+
...visualLineBounds({ textarea }),
|
|
74
|
+
}) ?? false;
|
|
75
|
+
if (handled) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
// React's controlled value lands on the following commit. Placing the caret before
|
|
78
|
+
// that commit clamps it to the OLD draft's length and breaks multiline Down gating.
|
|
79
|
+
// Up stays on the first line of even a multiline recall so repeated Up can browse
|
|
80
|
+
// older entries. Down/Escape go to the end, including when restoring the draft.
|
|
81
|
+
pendingCaretRef.current = event.key === 'ArrowUp' ? 'start' : 'end';
|
|
82
|
+
requestCaretCommit();
|
|
83
|
+
// Recalling an identical string may not cause a render; the existing value is safe.
|
|
84
|
+
const offset = pendingCaretRef.current === 'start' ? 0 : textarea.value.length;
|
|
85
|
+
textarea.setSelectionRange(offset, offset);
|
|
86
|
+
}
|
|
87
|
+
return handled;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=useComposerHistoryKeys.hooks.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
|
|
1
2
|
/**
|
|
2
3
|
* @module useConversation
|
|
3
4
|
*
|
|
@@ -113,8 +114,9 @@ export function useConversation(options) {
|
|
|
113
114
|
return {
|
|
114
115
|
...m,
|
|
115
116
|
...(nextRunId !== undefined ? { runId: nextRunId } : {}),
|
|
116
|
-
events: run.events,
|
|
117
|
-
|
|
117
|
+
events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}),
|
|
118
|
+
// Replayed completion prefixes cannot erase a saved interrupted segment.
|
|
119
|
+
content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events: run.events }) }, {}),
|
|
118
120
|
runStatus,
|
|
119
121
|
...(isTerminalRunStatus({ status: runStatus }) ? { endedAt: Date.now() } : {}),
|
|
120
122
|
};
|
|
@@ -160,6 +162,7 @@ export function useConversation(options) {
|
|
|
160
162
|
setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
|
|
161
163
|
setScrollIntent(true);
|
|
162
164
|
await run.start({
|
|
165
|
+
assistantMessageId: assistantMessage.id,
|
|
163
166
|
history,
|
|
164
167
|
...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
|
|
165
168
|
conversationId,
|
|
@@ -191,6 +194,7 @@ export function useConversation(options) {
|
|
|
191
194
|
// attachment beside the user turn, the failure reads as the model ignoring it rather than as
|
|
192
195
|
// the client never having sent it.
|
|
193
196
|
await run.start({
|
|
197
|
+
assistantMessageId: resetAssistant.id,
|
|
194
198
|
history,
|
|
195
199
|
...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
|
|
196
200
|
conversationId,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* comparison can't do this — `start()`'s handlers must exist before the
|
|
18
18
|
* transport's promise resolves with the real id).
|
|
19
19
|
*/
|
|
20
|
+
import { mergeRunEvents } from '../../core/durable-projection.js';
|
|
20
21
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
21
22
|
function reportRunStreamHostEffectFailure(effectName, error) {
|
|
22
23
|
console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, error);
|
|
@@ -77,6 +78,15 @@ export function useRunStream({ transport }) {
|
|
|
77
78
|
const makeHandlers = useCallback((generation) => {
|
|
78
79
|
const isStale = () => generationRef.current !== generation;
|
|
79
80
|
return {
|
|
81
|
+
onCheckpoint: (message) => {
|
|
82
|
+
if (isStale())
|
|
83
|
+
return;
|
|
84
|
+
const statuses = { queued: 'streaming', running: 'streaming', succeeded: 'done', failed: 'error', canceled: 'canceled' };
|
|
85
|
+
setState((prev) => ({ ...prev, runId: message.runId ?? prev.runId,
|
|
86
|
+
status: message.runStatus ? statuses[message.runStatus] : prev.status,
|
|
87
|
+
events: mergeRunEvents({ saved: prev.events, incoming: message.events ?? [] }, {}),
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
80
90
|
onEvent: (ev) => {
|
|
81
91
|
if (isStale())
|
|
82
92
|
return;
|
|
@@ -93,12 +103,12 @@ export function useRunStream({ transport }) {
|
|
|
93
103
|
onError: (err) => {
|
|
94
104
|
if (isStale())
|
|
95
105
|
return;
|
|
96
|
-
setState((prev) => ({ ...prev, status: 'error', error: err }));
|
|
106
|
+
setState((prev) => ({ ...prev, status: 'error', error: err, events: [...prev.events, { kind: 'status', code: 'run_terminal', label: err.message }] }));
|
|
97
107
|
},
|
|
98
108
|
onDone: (finalEvents) => {
|
|
99
109
|
if (isStale())
|
|
100
110
|
return;
|
|
101
|
-
setState((prev) => ({ ...prev, status: prev.status === 'error' ? prev.status : 'done', events: finalEvents }));
|
|
111
|
+
setState((prev) => ({ ...prev, status: prev.status === 'error' || prev.status === 'canceled' ? prev.status : 'done', events: mergeRunEvents({ saved: prev.events, incoming: finalEvents }, {}) }));
|
|
102
112
|
},
|
|
103
113
|
};
|
|
104
114
|
}, []);
|
package/dist/react/index.d.ts
CHANGED
|
@@ -163,4 +163,6 @@ export type { JiniChatProviderProps, JiniChatSlots } from './components/JiniChat
|
|
|
163
163
|
export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
|
|
164
164
|
export type { FrontendSessionBridge, FrontendSessionBridgeArgs, FrontendSessionBridgeOptions, } from './agent-bridge/frontend-session-bridge.js';
|
|
165
165
|
export { createDomPageDriver, currentAgentPage, type DomPageDriverOptions } from '@jini-ai/agentic/dom';
|
|
166
|
+
export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
|
|
167
|
+
export type { ComposerHistoryStoragePort } from '../core/composer-history.js';
|
|
166
168
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/react/index.js
CHANGED
|
@@ -121,4 +121,5 @@ export { JiniChatProvider, useJiniChatSlots, useOnFeedback } from './components/
|
|
|
121
121
|
*/
|
|
122
122
|
export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
|
|
123
123
|
export { createDomPageDriver, currentAgentPage } from '@jini-ai/agentic/dom';
|
|
124
|
+
export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
|
|
124
125
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/chat",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -145,10 +145,10 @@
|
|
|
145
145
|
"registry": "https://registry.npmjs.org"
|
|
146
146
|
},
|
|
147
147
|
"dependencies": {
|
|
148
|
-
"@jini-ai/
|
|
148
|
+
"@jini-ai/protocol": "^0.4.1",
|
|
149
|
+
"@jini-ai/core": "^0.4.1",
|
|
149
150
|
"@jini-ai/ui": "^0.4.4",
|
|
150
|
-
"@jini-ai/
|
|
151
|
-
"@jini-ai/core": "^0.4.1"
|
|
151
|
+
"@jini-ai/agentic": "^0.4.2"
|
|
152
152
|
},
|
|
153
153
|
"peerDependencies": {
|
|
154
154
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -238,8 +238,8 @@
|
|
|
238
238
|
"@electric-sql/pglite": "0.5.8",
|
|
239
239
|
"pg": "8.23.0",
|
|
240
240
|
"@ag-ui/core": "0.0.58",
|
|
241
|
-
"@jini-ai/
|
|
242
|
-
"@jini-ai/
|
|
241
|
+
"@jini-ai/agent-runtime": "0.4.1",
|
|
242
|
+
"@jini-ai/db": "0.3.0"
|
|
243
243
|
},
|
|
244
244
|
"typesVersions": {
|
|
245
245
|
"*": {
|