@micha.bigler/ui-core-micha 4.1.0 → 4.3.0
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 +33 -0
- package/README.md +9 -0
- package/dist/chat/ChatComposer.js +2 -2
- package/dist/chat/ChatWorkspace.js +199 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.3.0 - UCM-CHAT-5
|
|
4
|
+
|
|
5
|
+
`ChatWorkspace` gets three optional props; without them it behaves as in 4.2.x. `getSendContext()` (synchronous, no
|
|
6
|
+
arguments) is called once per `send` frame, right before it is sent, for every route: the composer, `send(text)` from
|
|
7
|
+
the answer slot, `sendRequest` and the retry of a failed turn. A plain object is sent as `context` on the frame
|
|
8
|
+
(`{ type: 'send', message, context }`, read by `django-core-micha` 3.3.0, DCM-CHAT-11); `undefined` or `null` sends no
|
|
9
|
+
`context`; anything else, or a thrown error, sends nothing, keeps the draft and shows the generic action-error notice
|
|
10
|
+
(a failed retry also keeps the failed turn on screen). The surface does not interpret or size-check the context; the core
|
|
11
|
+
answers `invalid_frame` for a bad or oversized one. The context never appears in the displayed turn text.
|
|
12
|
+
`sendRequest` (`{ id, text }`) sends `text` as the next message of the shown chat, like a typed send including
|
|
13
|
+
`getSendContext`. Each new `id` is handled once; with no chat shown, an archived chat, no connection, a running turn,
|
|
14
|
+
blank text or `activeChatId` `null` it is not sent. A request that arrives together with an `activeChatId` switch waits
|
|
15
|
+
for that chat. `onSendRequestHandled({ id, sent })` is called once per request so a host can fall back to
|
|
16
|
+
`composerPrefill` when `sent` is `false`. Give every request a fresh `id` (a counter); an `id` that was answered earlier
|
|
17
|
+
is treated as the same request and ignored.
|
|
18
|
+
|
|
19
|
+
## 4.2.0 - UCM-CHAT-4
|
|
20
|
+
|
|
21
|
+
`ChatWorkspace` takes two optional props so a host can control which chat is shown. `activeChatId` shows that chat
|
|
22
|
+
(`null` shows none, `undefined`, the default, keeps the selection internal); an id that is not in the loaded lists
|
|
23
|
+
triggers one reload of the lists, and an id that still cannot be found leaves the normal no-chat state. It is applied
|
|
24
|
+
only when the prop value changes, so a host that echoes `onActiveChatChange` back causes no extra requests.
|
|
25
|
+
`onActiveChatChange(chatId)` is called when the id of the shown chat changes (selection, creation, deletion as `null`,
|
|
26
|
+
a change driven by `activeChatId`), not for the initial state with no chat and not when the shown chat is only
|
|
27
|
+
archived, renamed or updated.
|
|
28
|
+
`composerPrefill` (`{ id, text }`) puts the text into the composer of the shown chat and focuses it; each new `id` is
|
|
29
|
+
applied (the same text twice, if asked twice), and a request with no chat shown, for an archived chat, or while
|
|
30
|
+
`activeChatId` is `null` is ignored. A prefill sent together with an `activeChatId` switch waits for the requested chat.
|
|
31
|
+
`renderAnswerExtra` additionally receives `send(text)`, which sends the text as the next message exactly like a typed
|
|
32
|
+
send, and `canSend` (false while a turn runs, for an archived chat and while disconnected). `ChatComposer` takes an
|
|
33
|
+
optional `inputRef`. Switching chats now clears the previous chat's content before paint.
|
|
34
|
+
Without the new props nothing changes.
|
|
35
|
+
|
|
3
36
|
## 4.1.0 - UCM-CHAT-3
|
|
4
37
|
|
|
5
38
|
Extension points on the `/chat` surface; with none of the new props `ChatWorkspace` renders and behaves as in 4.0.x.
|
package/README.md
CHANGED
|
@@ -44,3 +44,12 @@ The main entry needs only the required peers. Charts, the chat surface and `Mark
|
|
|
44
44
|
extra peers are optional: `@micha.bigler/ui-core-micha/charts` (`@mui/x-charts`), `/chat` and `/markdown`
|
|
45
45
|
(`react-markdown`, `remark-gfm`). Install those peers only in apps that import the sub-path. The 4.0.0 entry in
|
|
46
46
|
`CHANGELOG.md` has the migration table.
|
|
47
|
+
|
|
48
|
+
## Shared chat: host controls
|
|
49
|
+
|
|
50
|
+
`ChatWorkspace` (from `@micha.bigler/ui-core-micha/chat`) takes optional props for apps that put UI around the surface;
|
|
51
|
+
without them it behaves as before. Each is described in `CHANGELOG.md` under the release that added it.
|
|
52
|
+
Since 4.3.0: `getSendContext()` (a plain object attached as `context` to every `send` frame, read by
|
|
53
|
+
`django-core-micha` 3.3.0), `sendRequest` (`{ id, text }`, sends a message on the host's behalf) and
|
|
54
|
+
`onSendRequestHandled({ id, sent })`. Since 4.2.0: `activeChatId`, `onActiveChatChange`, `composerPrefill` and
|
|
55
|
+
`send(text)` / `canSend` in `renderAnswerExtra`.
|
|
@@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next';
|
|
|
6
6
|
* Message input. Send is disabled while a turn runs, for an archived chat and while the connection is down;
|
|
7
7
|
* Stop replaces it during a turn. Enter sends, Shift+Enter inserts a line break.
|
|
8
8
|
*/
|
|
9
|
-
export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, sx }) {
|
|
9
|
+
export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, inputRef, sx }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
const blocked = archived || !connected;
|
|
12
12
|
const canSend = !running && !blocked && value.trim().length > 0;
|
|
13
13
|
const submit = () => { if (canSend)
|
|
14
14
|
onSend === null || onSend === void 0 ? void 0 : onSend(); };
|
|
15
|
-
return (_jsxs(Stack, { direction: "row", alignItems: "flex-end", gap: 1, sx: Object.assign({ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider' }, sx), children: [_jsx(TextField, { multiline: true, maxRows: 6, fullWidth: true, size: "small", value: value, disabled: blocked, placeholder: t('Chat.message'), slotProps: { htmlInput: { 'aria-label': t('Chat.message') } }, onChange: (event) => onChange === null || onChange === void 0 ? void 0 : onChange(event.target.value), onKeyDown: (event) => {
|
|
15
|
+
return (_jsxs(Stack, { direction: "row", alignItems: "flex-end", gap: 1, sx: Object.assign({ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider' }, sx), children: [_jsx(TextField, { multiline: true, maxRows: 6, fullWidth: true, size: "small", value: value, inputRef: inputRef, disabled: blocked, placeholder: t('Chat.message'), slotProps: { htmlInput: { 'aria-label': t('Chat.message') } }, onChange: (event) => onChange === null || onChange === void 0 ? void 0 : onChange(event.target.value), onKeyDown: (event) => {
|
|
16
16
|
var _a;
|
|
17
17
|
if (event.key === 'Enter' && !event.shiftKey && !((_a = event.nativeEvent) === null || _a === void 0 ? void 0 : _a.isComposing)) {
|
|
18
18
|
event.preventDefault();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Alert, Box, Button, Chip, Drawer, Stack, Typography, useMediaQuery, useTheme } from '@mui/material';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { createChatApi } from './api';
|
|
@@ -9,6 +9,26 @@ import ChatThread from './ChatThread';
|
|
|
9
9
|
import ChatComposer from './ChatComposer';
|
|
10
10
|
import ModelPicker from './ModelPicker';
|
|
11
11
|
const PENDING_ID = 'pending';
|
|
12
|
+
const buildSendFrame = (message, getContext) => {
|
|
13
|
+
if (typeof getContext !== 'function')
|
|
14
|
+
return { frame: { type: 'send', message } };
|
|
15
|
+
try {
|
|
16
|
+
const context = getContext();
|
|
17
|
+
if (context == null)
|
|
18
|
+
return { frame: { type: 'send', message } };
|
|
19
|
+
if (typeof context !== 'object' || Array.isArray(context))
|
|
20
|
+
return { error: true };
|
|
21
|
+
const prototype = Object.getPrototypeOf(context);
|
|
22
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
23
|
+
return { error: true };
|
|
24
|
+
if (typeof JSON.stringify(context) !== 'string')
|
|
25
|
+
return { error: true };
|
|
26
|
+
return { frame: { type: 'send', message, context } };
|
|
27
|
+
}
|
|
28
|
+
catch (_a) {
|
|
29
|
+
return { error: true };
|
|
30
|
+
}
|
|
31
|
+
};
|
|
12
32
|
const bySeq = (a, b) => { var _a, _b; return ((_a = a.seq) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER) - ((_b = b.seq) !== null && _b !== void 0 ? _b : Number.MAX_SAFE_INTEGER); };
|
|
13
33
|
const chronological = (turns) => [...(turns || [])].sort(bySeq);
|
|
14
34
|
const isGenerating = (turn) => turn.state === 'generating';
|
|
@@ -42,7 +62,7 @@ const appendDelta = (turns, text) => {
|
|
|
42
62
|
* Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
|
|
43
63
|
* calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
|
|
44
64
|
*/
|
|
45
|
-
export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, layout = 'viewport', compactBelow = 720 }) {
|
|
65
|
+
export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, getSendContext, sendRequest, onSendRequestHandled, layout = 'viewport', compactBelow = 720 }) {
|
|
46
66
|
const { t } = useTranslation();
|
|
47
67
|
const theme = useTheme();
|
|
48
68
|
const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
|
|
@@ -70,17 +90,36 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
70
90
|
const [archived, setArchived] = useState([]);
|
|
71
91
|
const [nextChats, setNextChats] = useState(null);
|
|
72
92
|
const [chat, setChat] = useState(null);
|
|
93
|
+
const [listsLoaded, setListsLoaded] = useState(false);
|
|
73
94
|
const [turns, setTurns] = useState([]);
|
|
74
95
|
const [olderCursor, setOlderCursor] = useState(null);
|
|
75
96
|
const [olderBusy, setOlderBusy] = useState(false);
|
|
76
97
|
const [tool, setTool] = useState(null);
|
|
77
98
|
const [draft, setDraft] = useState('');
|
|
99
|
+
const composerInputRef = useRef(null);
|
|
100
|
+
const lastPrefillId = useRef(undefined);
|
|
101
|
+
const lastSendRequestId = useRef(undefined);
|
|
102
|
+
const deferredSendRequest = useRef(null); // `{ id, wanted }`: a request that had to wait for the chat named by `activeChatId`
|
|
103
|
+
const connectedChatId = useRef(null);
|
|
104
|
+
const getSendContextRef = useRef(getSendContext);
|
|
105
|
+
getSendContextRef.current = getSendContext;
|
|
106
|
+
const sendRequestHandledRef = useRef(onSendRequestHandled);
|
|
107
|
+
sendRequestHandledRef.current = onSendRequestHandled;
|
|
108
|
+
const [focusRequest, setFocusRequest] = useState(0);
|
|
109
|
+
const focusPending = useRef(false);
|
|
78
110
|
const [problem, setProblem] = useState(null);
|
|
79
111
|
const [unavailableReason, setUnavailableReason] = useState(null);
|
|
80
112
|
const [trimmedCount, setTrimmedCount] = useState(null);
|
|
81
113
|
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
|
82
114
|
const turnsRef = useRef(turns);
|
|
83
115
|
useEffect(() => { turnsRef.current = turns; }, [turns]);
|
|
116
|
+
const activeChangeCallback = useRef(onActiveChatChange);
|
|
117
|
+
activeChangeCallback.current = onActiveChatChange;
|
|
118
|
+
const lastReportedId = useRef(null);
|
|
119
|
+
const latestWantedId = useRef(activeChatId);
|
|
120
|
+
latestWantedId.current = activeChatId;
|
|
121
|
+
const appliedId = useRef(undefined);
|
|
122
|
+
const unloadableIds = useRef(new Set()); // ids the host asked for that no list contains
|
|
84
123
|
const [listOpen, setListOpen] = useState(false);
|
|
85
124
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
86
125
|
const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
|
|
@@ -90,7 +129,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
90
129
|
const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
|
|
91
130
|
// Bumped on every scope change so that a list response of the previous scope is dropped.
|
|
92
131
|
const scopeVersion = useRef(0);
|
|
93
|
-
useEffect(() => { scopeVersion.current += 1; setChat(null); }, [scope]);
|
|
132
|
+
useEffect(() => { scopeVersion.current += 1; setChat(null); setListsLoaded(false); appliedId.current = undefined; unloadableIds.current.clear(); }, [scope]);
|
|
94
133
|
const loadLists = useCallback(async () => {
|
|
95
134
|
const version = scopeVersion.current;
|
|
96
135
|
// Without a scope the api is called exactly as before the scope existed.
|
|
@@ -98,10 +137,11 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
98
137
|
? await Promise.all([api.listChats(), api.listChats({ archived: true })])
|
|
99
138
|
: await Promise.all([api.listChats({ scope }), api.listChats({ scope, archived: true })]);
|
|
100
139
|
if (version !== scopeVersion.current)
|
|
101
|
-
return;
|
|
140
|
+
return undefined;
|
|
102
141
|
setChats(active.results || []);
|
|
103
142
|
setNextChats(active.next || null);
|
|
104
143
|
setArchived(old.results || []);
|
|
144
|
+
return [...(active.results || []), ...(old.results || [])];
|
|
105
145
|
}, [api, scope]);
|
|
106
146
|
useEffect(() => {
|
|
107
147
|
let live = true;
|
|
@@ -119,6 +159,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
119
159
|
if (live) {
|
|
120
160
|
setStatus(state.status);
|
|
121
161
|
setCatalogue(entries);
|
|
162
|
+
setListsLoaded(true);
|
|
122
163
|
}
|
|
123
164
|
})
|
|
124
165
|
.catch(() => { if (live)
|
|
@@ -126,6 +167,53 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
126
167
|
return () => { live = false; };
|
|
127
168
|
}, [api, loadLists, scope]);
|
|
128
169
|
useEffect(() => {
|
|
170
|
+
var _a;
|
|
171
|
+
var _b;
|
|
172
|
+
const id = (_b = chat === null || chat === void 0 ? void 0 : chat.id) !== null && _b !== void 0 ? _b : null;
|
|
173
|
+
if (id === lastReportedId.current)
|
|
174
|
+
return;
|
|
175
|
+
lastReportedId.current = id;
|
|
176
|
+
(_a = activeChangeCallback.current) === null || _a === void 0 ? void 0 : _a.call(activeChangeCallback, id);
|
|
177
|
+
}, [chat === null || chat === void 0 ? void 0 : chat.id]);
|
|
178
|
+
// A layout effect: a host-driven switch to a known chat is applied before the browser paints.
|
|
179
|
+
useLayoutEffect(() => {
|
|
180
|
+
if (activeChatId === undefined || status !== 'available' || !listsLoaded)
|
|
181
|
+
return undefined;
|
|
182
|
+
const wantedKey = activeChatId === null ? null : String(activeChatId);
|
|
183
|
+
if (wantedKey === null) {
|
|
184
|
+
appliedId.current = null;
|
|
185
|
+
setChat(null);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (String(chat === null || chat === void 0 ? void 0 : chat.id) === wantedKey)
|
|
189
|
+
return;
|
|
190
|
+
const existing = [...chats, ...archived].find((item) => String(item.id) === wantedKey);
|
|
191
|
+
if (existing) {
|
|
192
|
+
setChat(existing);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (appliedId.current === wantedKey)
|
|
196
|
+
return;
|
|
197
|
+
appliedId.current = wantedKey;
|
|
198
|
+
let live = true;
|
|
199
|
+
loadLists().then((loaded) => {
|
|
200
|
+
if (!live || loaded === undefined || latestWantedId.current === undefined || String(latestWantedId.current) !== wantedKey)
|
|
201
|
+
return;
|
|
202
|
+
const found = loaded.find((item) => String(item.id) === wantedKey);
|
|
203
|
+
if (!found)
|
|
204
|
+
unloadableIds.current.add(wantedKey);
|
|
205
|
+
setChat(found || null);
|
|
206
|
+
}).catch(() => {
|
|
207
|
+
if (live && latestWantedId.current !== undefined && String(latestWantedId.current) === wantedKey) {
|
|
208
|
+
unloadableIds.current.add(wantedKey);
|
|
209
|
+
setChat(null);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
return () => { live = false; };
|
|
213
|
+
}, [activeChatId, status, listsLoaded]);
|
|
214
|
+
// Clear the previous chat's content before the browser paints, so a host-driven switch never flashes it under the new title.
|
|
215
|
+
useLayoutEffect(() => {
|
|
216
|
+
connectedChatId.current = null; // the socket of the new chat has not opened yet
|
|
129
217
|
setTurns([]);
|
|
130
218
|
setOlderCursor(null);
|
|
131
219
|
setTool(null);
|
|
@@ -134,6 +222,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
134
222
|
setSidePanelOpen(false);
|
|
135
223
|
setDraft('');
|
|
136
224
|
setOpenedOnce(false);
|
|
225
|
+
}, [chat === null || chat === void 0 ? void 0 : chat.id]);
|
|
226
|
+
useEffect(() => {
|
|
137
227
|
if (!(chat === null || chat === void 0 ? void 0 : chat.id))
|
|
138
228
|
return undefined;
|
|
139
229
|
let live = true;
|
|
@@ -177,10 +267,88 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
177
267
|
setUnavailableReason(frame.reason || unavailableReason);
|
|
178
268
|
}
|
|
179
269
|
}, [chat === null || chat === void 0 ? void 0 : chat.id]);
|
|
180
|
-
const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
|
|
181
|
-
const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
|
|
270
|
+
const onOpen = useCallback((chatId) => { connectedChatId.current = String(chatId); setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
|
|
271
|
+
const onClose = useCallback(({ reason, retrying }) => { connectedChatId.current = null; if (!retrying)
|
|
182
272
|
setProblem(reason); }, []);
|
|
183
273
|
const socket = useChatSocket(chat === null || chat === void 0 ? void 0 : chat.id, { wsUrl, onFrame, onOpen, onClose });
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (composerPrefill == null || composerPrefill.id === lastPrefillId.current)
|
|
276
|
+
return;
|
|
277
|
+
// `null` means the host wants no chat shown: nothing to fill, and the chat being left must not receive the text.
|
|
278
|
+
if (activeChatId === null) {
|
|
279
|
+
lastPrefillId.current = composerPrefill.id;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// The host switches chats and prefills in one go: wait for the requested chat instead of filling the one being left.
|
|
283
|
+
const wanted = activeChatId == null ? null : String(activeChatId);
|
|
284
|
+
if (wanted !== null && String(chat === null || chat === void 0 ? void 0 : chat.id) !== wanted && !unloadableIds.current.has(wanted))
|
|
285
|
+
return;
|
|
286
|
+
lastPrefillId.current = composerPrefill.id;
|
|
287
|
+
if (!chat || isArchived)
|
|
288
|
+
return;
|
|
289
|
+
setDraft(composerPrefill.text);
|
|
290
|
+
focusPending.current = true;
|
|
291
|
+
setFocusRequest((count) => count + 1);
|
|
292
|
+
}, [composerPrefill, chat, isArchived, activeChatId]);
|
|
293
|
+
// Host send request, handled once per id. A request that arrives before the chat named by `activeChatId` is shown waits
|
|
294
|
+
// for that chat AND for its socket to open (sending needs a connected socket of that chat); a chat that cannot be found
|
|
295
|
+
// ends the wait. Every other request is decided at once. Decided = sent if the surface can send right now, otherwise
|
|
296
|
+
// reported as not sent.
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
var _a;
|
|
299
|
+
const answerFor = (id, sent) => {
|
|
300
|
+
var _a, _b;
|
|
301
|
+
lastSendRequestId.current = id;
|
|
302
|
+
if (((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === id)
|
|
303
|
+
deferredSendRequest.current = null;
|
|
304
|
+
(_b = sendRequestHandledRef.current) === null || _b === void 0 ? void 0 : _b.call(sendRequestHandledRef, { id, sent });
|
|
305
|
+
};
|
|
306
|
+
// A waiting request that the host replaced or withdrew is answered as not sent, exactly once.
|
|
307
|
+
const waiting = deferredSendRequest.current;
|
|
308
|
+
if (waiting && waiting.id !== (sendRequest === null || sendRequest === void 0 ? void 0 : sendRequest.id))
|
|
309
|
+
answerFor(waiting.id, false);
|
|
310
|
+
if (sendRequest == null || sendRequest.id === lastSendRequestId.current)
|
|
311
|
+
return;
|
|
312
|
+
const answer = (sent) => answerFor(sendRequest.id, sent);
|
|
313
|
+
const blank = typeof sendRequest.text !== 'string' || !sendRequest.text.trim();
|
|
314
|
+
if (activeChatId === null || blank) {
|
|
315
|
+
answer(false);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const wanted = activeChatId === undefined ? null : String(activeChatId);
|
|
319
|
+
const deferred = ((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === sendRequest.id ? deferredSendRequest.current : null;
|
|
320
|
+
// The host named another chat while the request was waiting: it belonged to the earlier one, so it is not sent.
|
|
321
|
+
if (deferred && deferred.wanted !== wanted) {
|
|
322
|
+
answer(false);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (wanted !== null && !unloadableIds.current.has(wanted)) {
|
|
326
|
+
if (String(chat === null || chat === void 0 ? void 0 : chat.id) !== wanted) {
|
|
327
|
+
deferredSendRequest.current = { id: sendRequest.id, wanted };
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Only a request that could be sent once connected is worth waiting for.
|
|
331
|
+
if (deferred && connectedChatId.current !== wanted && !isArchived && !running)
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const text = sendRequest.text;
|
|
335
|
+
const sent = Boolean(canSendRef.current && typeof text === 'string' && text.trim() && startTurn(text));
|
|
336
|
+
if (sent)
|
|
337
|
+
canSendRef.current = false;
|
|
338
|
+
answer(sent);
|
|
339
|
+
}, [sendRequest, chat, activeChatId, socket.connected, running]);
|
|
340
|
+
// Runs after the render that carries the prefilled draft; a counter, so the same text requested twice focuses twice.
|
|
341
|
+
// A disabled input (connection down) cannot take focus, so the request stays pending until it is enabled again.
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
var _a;
|
|
344
|
+
const input = composerInputRef.current;
|
|
345
|
+
if (!focusPending.current || !input || input.disabled)
|
|
346
|
+
return;
|
|
347
|
+
focusPending.current = false;
|
|
348
|
+
input.focus();
|
|
349
|
+
const end = input.value.length;
|
|
350
|
+
(_a = input.setSelectionRange) === null || _a === void 0 ? void 0 : _a.call(input, end, end);
|
|
351
|
+
}, [focusRequest, socket.connected]);
|
|
184
352
|
const guarded = (action) => async (...args) => {
|
|
185
353
|
try {
|
|
186
354
|
return await action(...args);
|
|
@@ -226,7 +394,12 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
226
394
|
}
|
|
227
395
|
});
|
|
228
396
|
const startTurn = (message) => {
|
|
229
|
-
|
|
397
|
+
const built = buildSendFrame(message, getSendContextRef.current);
|
|
398
|
+
if (built.error) {
|
|
399
|
+
setProblem('action_error');
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (!socket.send(built.frame))
|
|
230
403
|
return false;
|
|
231
404
|
setProblem(null);
|
|
232
405
|
setTurns((current) => [...current, {
|
|
@@ -235,13 +408,29 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
235
408
|
}]);
|
|
236
409
|
return true;
|
|
237
410
|
};
|
|
411
|
+
const canSendFromSlot = Boolean(chat && !isArchived && socket.connected && !running);
|
|
412
|
+
// A slot may keep an old `send` around: read the latest availability, and close it right away after a send.
|
|
413
|
+
const canSendRef = useRef(false);
|
|
414
|
+
canSendRef.current = canSendFromSlot;
|
|
415
|
+
const sendFromSlot = (text) => {
|
|
416
|
+
const message = typeof text === 'string' ? text : '';
|
|
417
|
+
if (!canSendRef.current || !message.trim())
|
|
418
|
+
return false;
|
|
419
|
+
const started = startTurn(message);
|
|
420
|
+
if (started)
|
|
421
|
+
canSendRef.current = false;
|
|
422
|
+
return started;
|
|
423
|
+
};
|
|
424
|
+
const answerExtra = renderAnswerExtra
|
|
425
|
+
? (args) => renderAnswerExtra(Object.assign(Object.assign({}, args), { send: sendFromSlot, canSend: canSendFromSlot }))
|
|
426
|
+
: undefined;
|
|
238
427
|
const send = () => { if (startTurn(draft))
|
|
239
428
|
setDraft(''); };
|
|
240
429
|
const retry = (turn) => {
|
|
241
430
|
if (!socket.connected)
|
|
242
431
|
return;
|
|
243
|
-
|
|
244
|
-
|
|
432
|
+
if (startTurn(turn.user_text))
|
|
433
|
+
setTurns((current) => current.filter((item) => item.id !== turn.id));
|
|
245
434
|
};
|
|
246
435
|
const noticeFor = () => {
|
|
247
436
|
const texts = {
|
|
@@ -273,6 +462,6 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
273
462
|
if (unavailable) {
|
|
274
463
|
return (_jsx(Stack, { alignItems: "center", justifyContent: "center", spacing: 1, sx: Object.assign({ minHeight: 320, p: 3, textAlign: 'center' }, sx), children: _jsx(Typography, { color: "text.secondary", children: unavailableText || t('Chat.notAvailableHere') }) }));
|
|
275
464
|
}
|
|
276
|
-
return (_jsxs(Box, { ref: rootRef, sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : `minmax(240px, 320px) minmax(0, 1fr)${chat && sidePanel ? ' minmax(240px, 320px)' : ''}`, height: '100%', minHeight: 480, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden', bgcolor: 'background.paper' }, sx), children: [narrow ? null : _jsx(Box, { sx: { borderRight: 1, borderColor: 'divider', minHeight: 0 }, children: list }), _jsxs(Stack, { sx: { minWidth: 0, minHeight: 0 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }, children: [narrow ? _jsx(Button, { color: "inherit", onClick: () => setListOpen(true), children: t('Chat.chats') }) : null, _jsx(Typography, { noWrap: true, sx: { flex: 1, fontWeight: 600 }, children: chat ? (chat.title || t('Chat.untitled')) : '' }), socket.status === 'connecting' && openedOnce ? _jsx(Chip, { size: "small", color: "warning", variant: "outlined", label: t('Chat.reconnecting') }) : null, isArchived ? _jsx(Chip, { size: "small", label: t('Chat.archived') }) : null, isArchived ? _jsx(Button, { size: "small", onClick: () => updateChat(chat, { archived: false }), children: t('Chat.restore') }) : null, chat ? (_jsx(ModelPicker, { entries: catalogue.entries, selection: selection, disabled: isArchived || running, open: pickerOpen, onOpenChange: setPickerOpen, onChange: (next) => updateChat(chat, { selection: next }), sheet: narrow })) : null, chat && sidePanel && narrow ? _jsx(Button, { color: "inherit", onClick: () => setSidePanelOpen(true), children: sidePanelLabel || t('Chat.sidePanel') }) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, chat: chat, renderAnswerExtra:
|
|
465
|
+
return (_jsxs(Box, { ref: rootRef, sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : `minmax(240px, 320px) minmax(0, 1fr)${chat && sidePanel ? ' minmax(240px, 320px)' : ''}`, height: '100%', minHeight: 480, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden', bgcolor: 'background.paper' }, sx), children: [narrow ? null : _jsx(Box, { sx: { borderRight: 1, borderColor: 'divider', minHeight: 0 }, children: list }), _jsxs(Stack, { sx: { minWidth: 0, minHeight: 0 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }, children: [narrow ? _jsx(Button, { color: "inherit", onClick: () => setListOpen(true), children: t('Chat.chats') }) : null, _jsx(Typography, { noWrap: true, sx: { flex: 1, fontWeight: 600 }, children: chat ? (chat.title || t('Chat.untitled')) : '' }), socket.status === 'connecting' && openedOnce ? _jsx(Chip, { size: "small", color: "warning", variant: "outlined", label: t('Chat.reconnecting') }) : null, isArchived ? _jsx(Chip, { size: "small", label: t('Chat.archived') }) : null, isArchived ? _jsx(Button, { size: "small", onClick: () => updateChat(chat, { archived: false }), children: t('Chat.restore') }) : null, chat ? (_jsx(ModelPicker, { entries: catalogue.entries, selection: selection, disabled: isArchived || running, open: pickerOpen, onOpenChange: setPickerOpen, onChange: (next) => updateChat(chat, { selection: next }), sheet: narrow })) : null, chat && sidePanel && narrow ? _jsx(Button, { color: "inherit", onClick: () => setSidePanelOpen(true), children: sidePanelLabel || t('Chat.sidePanel') }) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, chat: chat, renderAnswerExtra: answerExtra, trimmedCount: trimmedCount, tool: tool, notice: noticeFor(), hasOlder: Boolean(olderCursor), olderBusy: olderBusy, onLoadOlder: loadOlder, onRetry: retry, readOnly: isArchived }), isArchived ? _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { px: 2, pb: 1 }, children: t('Chat.archivedReadOnly') }) : null, renderComposerExtra ? _jsx(Box, { children: renderComposerExtra({ chat }) }, chat.id) : null, _jsx(ChatComposer, { value: draft, onChange: setDraft, onSend: send, onStop: () => socket.send({ type: 'stop' }), running: running, archived: isArchived, connected: socket.connected, inputRef: composerInputRef })] })) : (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 2, sx: { flex: 1, p: 3 }, children: [_jsx(Typography, { color: "text.secondary", align: "center", children: t('Chat.selectChat') }), _jsx(Button, { variant: "contained", onClick: createChat, children: t('Chat.newChat') })] }))] }), chat && sidePanel && !narrow ? _jsx(Box, { sx: { borderLeft: 1, borderColor: 'divider', minHeight: 0, overflow: 'auto' }, children: sidePanel(chat) }, chat.id) : null, narrow && chat && sidePanel ? _jsx(Drawer, { anchor: "right", open: sidePanelOpen, onClose: () => setSidePanelOpen(false), slotProps: { paper: { 'aria-label': sidePanelLabel || t('Chat.sidePanel') } }, children: _jsx(Box, { children: sidePanel(chat) }, chat.id) }) : null, narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
|
|
277
466
|
}
|
|
278
467
|
export default ChatWorkspace;
|