@micha.bigler/ui-core-micha 4.1.0 → 4.2.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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.0 - UCM-CHAT-4
4
+
5
+ `ChatWorkspace` takes two optional props so a host can control which chat is shown. `activeChatId` shows that chat
6
+ (`null` shows none, `undefined`, the default, keeps the selection internal); an id that is not in the loaded lists
7
+ triggers one reload of the lists, and an id that still cannot be found leaves the normal no-chat state. It is applied
8
+ only when the prop value changes, so a host that echoes `onActiveChatChange` back causes no extra requests.
9
+ `onActiveChatChange(chatId)` is called when the id of the shown chat changes (selection, creation, deletion as `null`,
10
+ a change driven by `activeChatId`), not for the initial state with no chat and not when the shown chat is only
11
+ archived, renamed or updated.
12
+ `composerPrefill` (`{ id, text }`) puts the text into the composer of the shown chat and focuses it; each new `id` is
13
+ applied (the same text twice, if asked twice), and a request with no chat shown, for an archived chat, or while
14
+ `activeChatId` is `null` is ignored. A prefill sent together with an `activeChatId` switch waits for the requested chat.
15
+ `renderAnswerExtra` additionally receives `send(text)`, which sends the text as the next message exactly like a typed
16
+ send, and `canSend` (false while a turn runs, for an archived chat and while disconnected). `ChatComposer` takes an
17
+ optional `inputRef`. Switching chats now clears the previous chat's content before paint.
18
+ Without the new props nothing changes.
19
+
3
20
  ## 4.1.0 - UCM-CHAT-3
4
21
 
5
22
  Extension points on the `/chat` surface; with none of the new props `ChatWorkspace` renders and behaves as in 4.0.x.
@@ -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';
@@ -42,7 +42,7 @@ const appendDelta = (turns, text) => {
42
42
  * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
43
43
  * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
44
44
  */
45
- export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, layout = 'viewport', compactBelow = 720 }) {
45
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, layout = 'viewport', compactBelow = 720 }) {
46
46
  const { t } = useTranslation();
47
47
  const theme = useTheme();
48
48
  const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
@@ -70,17 +70,29 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
70
70
  const [archived, setArchived] = useState([]);
71
71
  const [nextChats, setNextChats] = useState(null);
72
72
  const [chat, setChat] = useState(null);
73
+ const [listsLoaded, setListsLoaded] = useState(false);
73
74
  const [turns, setTurns] = useState([]);
74
75
  const [olderCursor, setOlderCursor] = useState(null);
75
76
  const [olderBusy, setOlderBusy] = useState(false);
76
77
  const [tool, setTool] = useState(null);
77
78
  const [draft, setDraft] = useState('');
79
+ const composerInputRef = useRef(null);
80
+ const lastPrefillId = useRef(undefined);
81
+ const [focusRequest, setFocusRequest] = useState(0);
82
+ const focusPending = useRef(false);
78
83
  const [problem, setProblem] = useState(null);
79
84
  const [unavailableReason, setUnavailableReason] = useState(null);
80
85
  const [trimmedCount, setTrimmedCount] = useState(null);
81
86
  const [sidePanelOpen, setSidePanelOpen] = useState(false);
82
87
  const turnsRef = useRef(turns);
83
88
  useEffect(() => { turnsRef.current = turns; }, [turns]);
89
+ const activeChangeCallback = useRef(onActiveChatChange);
90
+ activeChangeCallback.current = onActiveChatChange;
91
+ const lastReportedId = useRef(null);
92
+ const latestWantedId = useRef(activeChatId);
93
+ latestWantedId.current = activeChatId;
94
+ const appliedId = useRef(undefined);
95
+ const unloadableIds = useRef(new Set()); // ids the host asked for that no list contains
84
96
  const [listOpen, setListOpen] = useState(false);
85
97
  const [pickerOpen, setPickerOpen] = useState(false);
86
98
  const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
@@ -90,7 +102,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
90
102
  const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
91
103
  // Bumped on every scope change so that a list response of the previous scope is dropped.
92
104
  const scopeVersion = useRef(0);
93
- useEffect(() => { scopeVersion.current += 1; setChat(null); }, [scope]);
105
+ useEffect(() => { scopeVersion.current += 1; setChat(null); setListsLoaded(false); appliedId.current = undefined; unloadableIds.current.clear(); }, [scope]);
94
106
  const loadLists = useCallback(async () => {
95
107
  const version = scopeVersion.current;
96
108
  // Without a scope the api is called exactly as before the scope existed.
@@ -98,10 +110,11 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
98
110
  ? await Promise.all([api.listChats(), api.listChats({ archived: true })])
99
111
  : await Promise.all([api.listChats({ scope }), api.listChats({ scope, archived: true })]);
100
112
  if (version !== scopeVersion.current)
101
- return;
113
+ return undefined;
102
114
  setChats(active.results || []);
103
115
  setNextChats(active.next || null);
104
116
  setArchived(old.results || []);
117
+ return [...(active.results || []), ...(old.results || [])];
105
118
  }, [api, scope]);
106
119
  useEffect(() => {
107
120
  let live = true;
@@ -119,6 +132,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
119
132
  if (live) {
120
133
  setStatus(state.status);
121
134
  setCatalogue(entries);
135
+ setListsLoaded(true);
122
136
  }
123
137
  })
124
138
  .catch(() => { if (live)
@@ -126,6 +140,52 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
126
140
  return () => { live = false; };
127
141
  }, [api, loadLists, scope]);
128
142
  useEffect(() => {
143
+ var _a;
144
+ var _b;
145
+ const id = (_b = chat === null || chat === void 0 ? void 0 : chat.id) !== null && _b !== void 0 ? _b : null;
146
+ if (id === lastReportedId.current)
147
+ return;
148
+ lastReportedId.current = id;
149
+ (_a = activeChangeCallback.current) === null || _a === void 0 ? void 0 : _a.call(activeChangeCallback, id);
150
+ }, [chat === null || chat === void 0 ? void 0 : chat.id]);
151
+ // A layout effect: a host-driven switch to a known chat is applied before the browser paints.
152
+ useLayoutEffect(() => {
153
+ if (activeChatId === undefined || status !== 'available' || !listsLoaded)
154
+ return undefined;
155
+ const wantedKey = activeChatId === null ? null : String(activeChatId);
156
+ if (wantedKey === null) {
157
+ appliedId.current = null;
158
+ setChat(null);
159
+ return;
160
+ }
161
+ if (String(chat === null || chat === void 0 ? void 0 : chat.id) === wantedKey)
162
+ return;
163
+ const existing = [...chats, ...archived].find((item) => String(item.id) === wantedKey);
164
+ if (existing) {
165
+ setChat(existing);
166
+ return;
167
+ }
168
+ if (appliedId.current === wantedKey)
169
+ return;
170
+ appliedId.current = wantedKey;
171
+ let live = true;
172
+ loadLists().then((loaded) => {
173
+ if (!live || loaded === undefined || latestWantedId.current === undefined || String(latestWantedId.current) !== wantedKey)
174
+ return;
175
+ const found = loaded.find((item) => String(item.id) === wantedKey);
176
+ if (!found)
177
+ unloadableIds.current.add(wantedKey);
178
+ setChat(found || null);
179
+ }).catch(() => {
180
+ if (live && latestWantedId.current !== undefined && String(latestWantedId.current) === wantedKey) {
181
+ unloadableIds.current.add(wantedKey);
182
+ setChat(null);
183
+ }
184
+ });
185
+ return () => { live = false; };
186
+ }, [activeChatId, status, listsLoaded]);
187
+ // Clear the previous chat's content before the browser paints, so a host-driven switch never flashes it under the new title.
188
+ useLayoutEffect(() => {
129
189
  setTurns([]);
130
190
  setOlderCursor(null);
131
191
  setTool(null);
@@ -134,6 +194,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
134
194
  setSidePanelOpen(false);
135
195
  setDraft('');
136
196
  setOpenedOnce(false);
197
+ }, [chat === null || chat === void 0 ? void 0 : chat.id]);
198
+ useEffect(() => {
137
199
  if (!(chat === null || chat === void 0 ? void 0 : chat.id))
138
200
  return undefined;
139
201
  let live = true;
@@ -181,6 +243,37 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
181
243
  const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
182
244
  setProblem(reason); }, []);
183
245
  const socket = useChatSocket(chat === null || chat === void 0 ? void 0 : chat.id, { wsUrl, onFrame, onOpen, onClose });
246
+ useEffect(() => {
247
+ if (composerPrefill == null || composerPrefill.id === lastPrefillId.current)
248
+ return;
249
+ // `null` means the host wants no chat shown: nothing to fill, and the chat being left must not receive the text.
250
+ if (activeChatId === null) {
251
+ lastPrefillId.current = composerPrefill.id;
252
+ return;
253
+ }
254
+ // The host switches chats and prefills in one go: wait for the requested chat instead of filling the one being left.
255
+ const wanted = activeChatId == null ? null : String(activeChatId);
256
+ if (wanted !== null && String(chat === null || chat === void 0 ? void 0 : chat.id) !== wanted && !unloadableIds.current.has(wanted))
257
+ return;
258
+ lastPrefillId.current = composerPrefill.id;
259
+ if (!chat || isArchived)
260
+ return;
261
+ setDraft(composerPrefill.text);
262
+ focusPending.current = true;
263
+ setFocusRequest((count) => count + 1);
264
+ }, [composerPrefill, chat, isArchived, activeChatId]);
265
+ // Runs after the render that carries the prefilled draft; a counter, so the same text requested twice focuses twice.
266
+ // A disabled input (connection down) cannot take focus, so the request stays pending until it is enabled again.
267
+ useEffect(() => {
268
+ var _a;
269
+ const input = composerInputRef.current;
270
+ if (!focusPending.current || !input || input.disabled)
271
+ return;
272
+ focusPending.current = false;
273
+ input.focus();
274
+ const end = input.value.length;
275
+ (_a = input.setSelectionRange) === null || _a === void 0 ? void 0 : _a.call(input, end, end);
276
+ }, [focusRequest, socket.connected]);
184
277
  const guarded = (action) => async (...args) => {
185
278
  try {
186
279
  return await action(...args);
@@ -235,6 +328,22 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
235
328
  }]);
236
329
  return true;
237
330
  };
331
+ const canSendFromSlot = Boolean(chat && !isArchived && socket.connected && !running);
332
+ // A slot may keep an old `send` around: read the latest availability, and close it right away after a send.
333
+ const canSendRef = useRef(false);
334
+ canSendRef.current = canSendFromSlot;
335
+ const sendFromSlot = (text) => {
336
+ const message = typeof text === 'string' ? text : '';
337
+ if (!canSendRef.current || !message.trim())
338
+ return false;
339
+ const started = startTurn(message);
340
+ if (started)
341
+ canSendRef.current = false;
342
+ return started;
343
+ };
344
+ const answerExtra = renderAnswerExtra
345
+ ? (args) => renderAnswerExtra(Object.assign(Object.assign({}, args), { send: sendFromSlot, canSend: canSendFromSlot }))
346
+ : undefined;
238
347
  const send = () => { if (startTurn(draft))
239
348
  setDraft(''); };
240
349
  const retry = (turn) => {
@@ -273,6 +382,6 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
273
382
  if (unavailable) {
274
383
  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
384
  }
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: renderAnswerExtra, 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 })] })) : (_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] }));
385
+ 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
386
  }
278
387
  export default ChatWorkspace;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {