@micha.bigler/ui-core-micha 4.0.1 → 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,35 @@
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
+
20
+ ## 4.1.0 - UCM-CHAT-3
21
+
22
+ Extension points on the `/chat` surface; with none of the new props `ChatWorkspace` renders and behaves as in 4.0.x.
23
+ New `ChatWorkspace` props: `scope` (now also sent on the chat lists and the status request), `unavailableReasonText`
24
+ (object or function mapping the server's `unavailable` reason to the app's text; generic fallback otherwise),
25
+ `sidePanel(chat)` with `sidePanelLabel` (a column next to the thread, a sheet in the narrow and compact layouts),
26
+ `renderAnswerExtra({ turn, chat, appFrames })` (under each answer, with the `app` frames that arrived during that turn),
27
+ `renderComposerExtra({ chat })`, `onAppFrame({ name, payload, chatId, turnId })`, and `layout` (`'viewport'` default,
28
+ `'container'` follows the container width below `compactBelow` px, `'compact'` always narrow). `ModelPicker` takes an
29
+ optional `sheet` boolean. A `history_trimmed` notice shows a quiet chip with the number of left-out messages. New texts
30
+ `Chat.notAvailableHere`, `Chat.historyTrimmed_one/_other` and the fallback `Chat.sidePanel` (de/en/fr/sw). Built
31
+ against the `django-core-micha` DCM-CHAT-10 contract.
32
+
3
33
  ## 4.0.1 - UCM-DEP-3
4
34
 
5
35
  The `exports` map no longer lists `./package.json`; the public entries are exactly `.`, `./charts`, `./chat` and `./markdown`.
@@ -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();
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import MarkdownReply from './MarkdownReply';
6
6
  export const CHAT_THREAD_TOOL_CHIP_SX = { alignSelf: 'flex-start' };
7
7
  export const CHAT_THREAD_NOTICE_SX = { mb: 2 };
8
+ export const CHAT_THREAD_TRIMMED_SX = { mb: 1.5 };
8
9
  const selectionLine = (turn, t) => [turn.model, turn.effort && t(`Chat.effort.${turn.effort}`)].filter(Boolean).join(' · ');
9
10
  const sameSelection = (a, b) => a.driver === b.driver && a.model === b.model && a.effort === b.effort;
10
11
  function SourceChips({ sources }) {
@@ -13,17 +14,18 @@ function SourceChips({ sources }) {
13
14
  return null;
14
15
  return (_jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.75, role: "list", "aria-label": t('Chat.sources'), children: sources.map((source, index) => (_jsx(Tooltip, { title: source.reference || '', describeChild: true, children: _jsx(Chip, { role: "listitem", size: "small", variant: "outlined", label: source.label }) }, `${source.label}-${index}`))) }));
15
16
  }
16
- function Answer({ turn, tool, onRetry }) {
17
+ function Answer({ turn, tool, onRetry, chat, renderAnswerExtra }) {
18
+ var _a;
17
19
  const { t } = useTranslation();
18
20
  const generating = turn.state === 'generating';
19
21
  const failed = ['failed', 'cancelled'].includes(turn.state) && turn.error_code;
20
- return (_jsxs(Stack, { spacing: 1, "aria-busy": generating, children: [turn.assistant_text ? _jsx(MarkdownReply, { text: turn.assistant_text }) : null, generating && !turn.assistant_text && !tool ? _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Chat.typing') }) : null, generating && tool ? _jsx(Chip, { size: "small", color: "primary", variant: "outlined", label: tool.label, sx: CHAT_THREAD_TOOL_CHIP_SX }) : null, _jsx(SourceChips, { sources: turn.sources }), !generating && selectionLine(turn, t) ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: selectionLine(turn, t) }) : null, turn.state === 'interrupted' ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Chip, { size: "small", color: "warning", label: t('Chat.interrupted') }), onRetry ? _jsx(Button, { size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null] })) : null, failed ? (_jsx(Alert, { severity: "error", action: onRetry ? _jsx(Button, { color: "inherit", size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null, children: t('Chat.answerIncomplete') })) : null] }));
22
+ return (_jsxs(Stack, { spacing: 1, "aria-busy": generating, children: [turn.assistant_text ? _jsx(MarkdownReply, { text: turn.assistant_text }) : null, generating && !turn.assistant_text && !tool ? _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Chat.typing') }) : null, generating && tool ? _jsx(Chip, { size: "small", color: "primary", variant: "outlined", label: tool.label, sx: CHAT_THREAD_TOOL_CHIP_SX }) : null, _jsx(SourceChips, { sources: turn.sources }), !generating && selectionLine(turn, t) ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: selectionLine(turn, t) }) : null, renderAnswerExtra ? _jsx(React.Fragment, { children: renderAnswerExtra({ turn, chat, appFrames: (_a = turn.appFrames) !== null && _a !== void 0 ? _a : [] }) }, chat === null || chat === void 0 ? void 0 : chat.id) : null, turn.state === 'interrupted' ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Chip, { size: "small", color: "warning", label: t('Chat.interrupted') }), onRetry ? _jsx(Button, { size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null] })) : null, failed ? (_jsx(Alert, { severity: "error", action: onRetry ? _jsx(Button, { color: "inherit", size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null, children: t('Chat.answerIncomplete') })) : null] }));
21
23
  }
22
24
  /**
23
25
  * The turns of one chat. The streaming answer is simply the last turn in state `generating`; the caller keeps its text
24
26
  * up to date. `notice` is a connection or limit message shown under the last turn. `readOnly` (an archived chat) hides Retry.
25
27
  */
26
- export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx }) {
28
+ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx, chat, renderAnswerExtra, trimmedCount }) {
27
29
  var _a;
28
30
  const { t } = useTranslation();
29
31
  const end = useRef(null);
@@ -64,7 +66,7 @@ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder =
64
66
  }, role: "log", "aria-live": "polite", "aria-label": t('Chat.thread'), sx: Object.assign({ minHeight: 0, flex: 1, overflowY: 'auto', p: { xs: 1.5, md: 2.5 } }, sx), children: [hasOlder ? (_jsx(Box, { sx: { textAlign: 'center', mb: 2 }, children: _jsx(Button, { size: "small", onClick: loadOlder, disabled: olderBusy, children: t('Chat.loadOlder') }) })) : null, turns.length === 0 ? (_jsx(Typography, { variant: "h6", color: "text.secondary", align: "center", sx: { mt: 6 }, children: t('Chat.emptyTitle') })) : null, turns.map((turn, index) => {
65
67
  var _a;
66
68
  const previous = turns[index - 1];
67
- return (_jsxs(Box, { sx: { mb: 3 }, children: [previous && !sameSelection(previous, turn) ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { mb: 2, color: 'text.secondary' }, role: "separator", children: [_jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } }), _jsx(Typography, { variant: "caption", children: t('Chat.modelChanged', { model: selectionLine(turn, t) }) }), _jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } })] })) : null, _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end', mb: 1.5 }, children: _jsx(Typography, { sx: { maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }, children: turn.user_text }) }), _jsx(Answer, { turn: turn, tool: index === turns.length - 1 ? tool : null, onRetry: readOnly ? undefined : onRetry })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
68
- }), notice ? (_jsx(Alert, { severity: notice.severity || 'warning', sx: CHAT_THREAD_NOTICE_SX, action: notice.action ? _jsx(Button, { color: "inherit", size: "small", onClick: notice.action.onClick, children: notice.action.label }) : null, children: notice.text })) : null, _jsx("div", { ref: end })] }));
69
+ return (_jsxs(Box, { sx: { mb: 3 }, children: [previous && !sameSelection(previous, turn) ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { mb: 2, color: 'text.secondary' }, role: "separator", children: [_jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } }), _jsx(Typography, { variant: "caption", children: t('Chat.modelChanged', { model: selectionLine(turn, t) }) }), _jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } })] })) : null, _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end', mb: 1.5 }, children: _jsx(Typography, { sx: { maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }, children: turn.user_text }) }), _jsx(Answer, { turn: turn, tool: index === turns.length - 1 ? tool : null, onRetry: readOnly ? undefined : onRetry, chat: chat, renderAnswerExtra: renderAnswerExtra })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
70
+ }), trimmedCount ? _jsx(Chip, { size: "small", variant: "outlined", label: t(trimmedCount === 1 ? 'Chat.historyTrimmed_one' : 'Chat.historyTrimmed_other', { count: trimmedCount }), sx: CHAT_THREAD_TRIMMED_SX }) : null, notice ? (_jsx(Alert, { severity: notice.severity || 'warning', sx: CHAT_THREAD_NOTICE_SX, action: notice.action ? _jsx(Button, { color: "inherit", size: "small", onClick: notice.action.onClick, children: notice.action.label }) : null, children: notice.text })) : null, _jsx("div", { ref: end })] }));
69
71
  }
70
72
  export default ChatThread;
@@ -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, 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';
@@ -21,6 +21,15 @@ const settleTurn = (turns, final) => {
21
21
  };
22
22
  const failGenerating = (turns, code) => turns.map((turn) => (isGenerating(turn)
23
23
  ? Object.assign(Object.assign({}, turn), { state: 'failed', error_code: code === 'quota_exceeded' ? null : code }) : turn));
24
+ /** Stores an app frame on the turn that is generating; frames outside a running turn are dropped. */
25
+ const attachAppFrame = (turns, frame) => {
26
+ const index = turns.findLastIndex(isGenerating);
27
+ if (index < 0)
28
+ return turns;
29
+ const turn = turns[index];
30
+ const appFrames = [...(turn.appFrames || []), { name: frame.name, payload: frame.payload }];
31
+ return turns.map((item, i) => (i === index ? Object.assign(Object.assign({}, turn), { appFrames }) : item));
32
+ };
24
33
  const appendDelta = (turns, text) => {
25
34
  const index = turns.findLastIndex(isGenerating);
26
35
  if (index < 0)
@@ -33,23 +42,57 @@ const appendDelta = (turns, text) => {
33
42
  * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
34
43
  * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
35
44
  */
36
- export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx }) {
45
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, layout = 'viewport', compactBelow = 720 }) {
37
46
  const { t } = useTranslation();
38
47
  const theme = useTheme();
39
- const narrow = useMediaQuery(theme.breakpoints.down('md'));
40
- const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
48
+ const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
49
+ const rootRef = useRef(null);
50
+ const [containerNarrow, setContainerNarrow] = useState(false);
41
51
  const [status, setStatus] = useState('loading'); // loading | available | not_configured | error
52
+ const narrow = layout === 'compact' || (layout === 'container' ? containerNarrow : viewportNarrow);
53
+ const appFrameCallback = useRef(onAppFrame);
54
+ useEffect(() => { appFrameCallback.current = onAppFrame; }, [onAppFrame]);
55
+ useEffect(() => {
56
+ if (layout !== 'container')
57
+ return undefined;
58
+ if (typeof ResizeObserver === 'undefined' || !rootRef.current) {
59
+ setContainerNarrow(viewportNarrow);
60
+ return undefined;
61
+ }
62
+ const observer = new ResizeObserver((entries) => { var _a, _b; const width = (_b = (_a = entries[0]) === null || _a === void 0 ? void 0 : _a.contentRect) === null || _b === void 0 ? void 0 : _b.width; if (width !== undefined)
63
+ setContainerNarrow(width < compactBelow); });
64
+ observer.observe(rootRef.current);
65
+ return () => observer.disconnect();
66
+ }, [layout, compactBelow, viewportNarrow, status]);
67
+ const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
42
68
  const [catalogue, setCatalogue] = useState({ entries: [], default: null });
43
69
  const [chats, setChats] = useState([]);
44
70
  const [archived, setArchived] = useState([]);
45
71
  const [nextChats, setNextChats] = useState(null);
46
72
  const [chat, setChat] = useState(null);
73
+ const [listsLoaded, setListsLoaded] = useState(false);
47
74
  const [turns, setTurns] = useState([]);
48
75
  const [olderCursor, setOlderCursor] = useState(null);
49
76
  const [olderBusy, setOlderBusy] = useState(false);
50
77
  const [tool, setTool] = useState(null);
51
78
  const [draft, setDraft] = useState('');
52
- const [problem, setProblem] = useState(null); // an error code or close reason, shown as a notice
79
+ const composerInputRef = useRef(null);
80
+ const lastPrefillId = useRef(undefined);
81
+ const [focusRequest, setFocusRequest] = useState(0);
82
+ const focusPending = useRef(false);
83
+ const [problem, setProblem] = useState(null);
84
+ const [unavailableReason, setUnavailableReason] = useState(null);
85
+ const [trimmedCount, setTrimmedCount] = useState(null);
86
+ const [sidePanelOpen, setSidePanelOpen] = useState(false);
87
+ const turnsRef = useRef(turns);
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
53
96
  const [listOpen, setListOpen] = useState(false);
54
97
  const [pickerOpen, setPickerOpen] = useState(false);
55
98
  const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
@@ -57,44 +100,108 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
57
100
  const isArchived = Boolean(chat === null || chat === void 0 ? void 0 : chat.archived_at);
58
101
  const selection = (chat === null || chat === void 0 ? void 0 : chat.selection) || catalogue.default;
59
102
  const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
103
+ // Bumped on every scope change so that a list response of the previous scope is dropped.
104
+ const scopeVersion = useRef(0);
105
+ useEffect(() => { scopeVersion.current += 1; setChat(null); setListsLoaded(false); appliedId.current = undefined; unloadableIds.current.clear(); }, [scope]);
60
106
  const loadLists = useCallback(async () => {
61
- const [active, old] = await Promise.all([api.listChats(), api.listChats({ archived: true })]);
107
+ const version = scopeVersion.current;
108
+ // Without a scope the api is called exactly as before the scope existed.
109
+ const [active, old] = scope === undefined
110
+ ? await Promise.all([api.listChats(), api.listChats({ archived: true })])
111
+ : await Promise.all([api.listChats({ scope }), api.listChats({ scope, archived: true })]);
112
+ if (version !== scopeVersion.current)
113
+ return undefined;
62
114
  setChats(active.results || []);
63
115
  setNextChats(active.next || null);
64
116
  setArchived(old.results || []);
65
- }, [api]);
117
+ return [...(active.results || []), ...(old.results || [])];
118
+ }, [api, scope]);
66
119
  useEffect(() => {
67
120
  let live = true;
68
- api.getStatus()
121
+ const request = scope === undefined ? api.getStatus() : api.getStatus({ scope });
122
+ request
69
123
  .then(async (state) => {
70
124
  if (state.status !== 'available') {
71
- if (live)
125
+ if (live) {
72
126
  setStatus(state.status);
127
+ setUnavailableReason(state.reason || null);
128
+ }
73
129
  return;
74
130
  }
75
131
  const [entries] = await Promise.all([api.getCatalogue(), loadLists()]);
76
132
  if (live) {
77
133
  setStatus(state.status);
78
134
  setCatalogue(entries);
135
+ setListsLoaded(true);
79
136
  }
80
137
  })
81
138
  .catch(() => { if (live)
82
139
  setStatus('error'); });
83
140
  return () => { live = false; };
84
- }, [api, loadLists]);
141
+ }, [api, loadLists, scope]);
85
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(() => {
86
189
  setTurns([]);
87
190
  setOlderCursor(null);
88
191
  setTool(null);
89
192
  setProblem(null);
193
+ setTrimmedCount(null);
194
+ setSidePanelOpen(false);
90
195
  setDraft('');
91
196
  setOpenedOnce(false);
197
+ }, [chat === null || chat === void 0 ? void 0 : chat.id]);
198
+ useEffect(() => {
92
199
  if (!(chat === null || chat === void 0 ? void 0 : chat.id))
93
200
  return undefined;
94
201
  let live = true;
95
202
  api.listTurns(chat.id)
96
203
  .then((page) => { if (live) {
97
- setTurns(chronological(page.results));
204
+ setTurns(chronological((page.results || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
98
205
  setOlderCursor(page.next || null);
99
206
  } })
100
207
  .catch(() => { if (live)
@@ -102,17 +209,25 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
102
209
  return () => { live = false; };
103
210
  }, [api, chat === null || chat === void 0 ? void 0 : chat.id]);
104
211
  const onFrame = useCallback((frame, chatId) => {
212
+ var _a;
105
213
  if (chatId !== (chat === null || chat === void 0 ? void 0 : chat.id))
106
214
  return;
107
215
  if (frame.type === 'history')
108
- setTurns(chronological(frame.turns));
216
+ setTurns(chronological((frame.turns || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
109
217
  else if (frame.type === 'delta')
110
218
  setTurns((current) => appendDelta(current, frame.text || ''));
219
+ else if (frame.type === 'app') {
220
+ setTurns((current) => attachAppFrame(current, frame));
221
+ const running = turnsRef.current.findLast(isGenerating);
222
+ (_a = appFrameCallback.current) === null || _a === void 0 ? void 0 : _a.call(appFrameCallback, { name: frame.name, payload: frame.payload, chatId, turnId: running === null || running === void 0 ? void 0 : running.id });
223
+ }
224
+ else if (frame.type === 'notice' && frame.code === 'history_trimmed')
225
+ setTrimmedCount(frame.left_out || 0);
111
226
  else if (frame.type === 'tool')
112
227
  setTool(frame.phase === 'start' ? { label: frame.label, name: frame.name } : null);
113
228
  else if (frame.type === 'turn' && frame.turn) {
114
229
  setTool(null);
115
- setTurns((current) => settleTurn(current, frame.turn));
230
+ setTurns((current) => { const pending = current.findLast(isGenerating); return settleTurn(current, Object.assign(Object.assign({}, frame.turn), { appFrames: (pending === null || pending === void 0 ? void 0 : pending.appFrames) || [] })); });
116
231
  if (frame.turn.error_code === 'quota_exceeded')
117
232
  setProblem('quota_exceeded');
118
233
  }
@@ -120,12 +235,45 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
120
235
  setTool(null);
121
236
  setTurns((current) => failGenerating(current, frame.code));
122
237
  setProblem(frame.code);
238
+ if (frame.code === 'unavailable')
239
+ setUnavailableReason(frame.reason || unavailableReason);
123
240
  }
124
241
  }, [chat === null || chat === void 0 ? void 0 : chat.id]);
125
242
  const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
126
243
  const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
127
244
  setProblem(reason); }, []);
128
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]);
129
277
  const guarded = (action) => async (...args) => {
130
278
  try {
131
279
  return await action(...args);
@@ -137,7 +285,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
137
285
  };
138
286
  const selectChat = (next) => { setChat(next); setListOpen(false); };
139
287
  const createChat = guarded(async () => {
140
- const created = await api.createChat(scope ? { scope } : {});
288
+ const created = await api.createChat(scope !== undefined ? { scope } : {});
141
289
  setChats((current) => [created, ...current]);
142
290
  selectChat(created);
143
291
  });
@@ -155,7 +303,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
155
303
  setChat(null);
156
304
  });
157
305
  const loadMoreChats = guarded(async () => {
158
- const page = await api.listChats({ cursor: nextChats });
306
+ const page = await api.listChats(Object.assign(Object.assign({}, (scope !== undefined ? { scope } : {})), { cursor: nextChats }));
159
307
  setChats((current) => [...current, ...(page.results || [])]);
160
308
  setNextChats(page.next || null);
161
309
  });
@@ -180,6 +328,22 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
180
328
  }]);
181
329
  return true;
182
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;
183
347
  const send = () => { if (startTurn(draft))
184
348
  setDraft(''); };
185
349
  const retry = (turn) => {
@@ -205,14 +369,19 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
205
369
  return null;
206
370
  };
207
371
  const list = (_jsx(ChatList, { chats: chats, archivedChats: archived, activeId: chat === null || chat === void 0 ? void 0 : chat.id, hasMore: Boolean(nextChats), onSelect: selectChat, onNew: createChat, onRename: (target, title) => updateChat(target, { title }), onArchive: (target) => updateChat(target, { archived: true }), onRestore: (target) => updateChat(target, { archived: false }), onDelete: deleteChat, onLoadMore: loadMoreChats }));
208
- const unavailable = status === 'not_configured' || problem === 'not_configured';
372
+ const notConfigured = status === 'not_configured' || problem === 'not_configured';
373
+ const unavailable = status === 'unavailable' || problem === 'unavailable';
374
+ const unavailableText = typeof unavailableReasonText === 'function' ? unavailableReasonText(unavailableReason) : unavailableReasonText === null || unavailableReasonText === void 0 ? void 0 : unavailableReasonText[unavailableReason];
209
375
  if (status === 'loading')
210
376
  return _jsx(Box, { sx: sx, "aria-busy": "true" });
211
377
  if (status === 'error')
212
378
  return _jsx(Box, { sx: sx, children: _jsx(Alert, { severity: "error", children: t('Chat.loadError') }) });
213
- if (unavailable) {
379
+ if (notConfigured) {
214
380
  return (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 1, sx: Object.assign({ minHeight: 320, p: 3, textAlign: 'center' }, sx), children: [_jsx(Typography, { variant: "h6", children: t('Chat.unavailable') }), _jsx(Typography, { color: "text.secondary", children: t('Chat.noModel') })] }));
215
381
  }
216
- return (_jsxs(Box, { sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : 'minmax(240px, 320px) minmax(0, 1fr)', 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 }) })) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, 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, _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') })] }))] }), narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
382
+ if (unavailable) {
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') }) }));
384
+ }
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] }));
217
386
  }
218
387
  export default ChatWorkspace;
@@ -12,10 +12,11 @@ const groupByDriver = (entries) => entries.reduce((groups, entry) => {
12
12
  * Model and effort of one chat. A popover at wide widths, a bottom sheet at narrow widths. `onChange` receives
13
13
  * `{ entry, effort }`; the caller persists it (the change applies from the next turn). Can be controlled with `open`.
14
14
  */
15
- export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange }) {
15
+ export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange, sheet }) {
16
16
  const { t } = useTranslation();
17
17
  const theme = useTheme();
18
- const narrow = useMediaQuery(theme.breakpoints.down('md'));
18
+ const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
19
+ const narrow = sheet !== null && sheet !== void 0 ? sheet : viewportNarrow;
19
20
  const anchor = useRef(null);
20
21
  const [innerOpen, setInnerOpen] = useState(false);
21
22
  const isOpen = open !== null && open !== void 0 ? open : innerOpen;
package/dist/chat/api.js CHANGED
@@ -5,12 +5,12 @@ export function createChatApi(overrides = {}, { apiBase = '/api/assistant/' } =
5
5
  const data = (request) => request.then((response) => response.data);
6
6
  const defaults = {
7
7
  getCatalogue: () => data(apiClient.get(urls.catalogue)),
8
- listChats: ({ archived = false, cursor } = {}) => data(apiClient.get(urls.chats, { params: Object.assign({ archived }, (cursor ? { cursor } : {})) })),
8
+ listChats: ({ archived = false, cursor, scope } = {}) => data(apiClient.get(urls.chats, { params: Object.assign(Object.assign({ archived }, (cursor ? { cursor } : {})), (scope !== undefined ? { scope } : {})) })),
9
9
  createChat: (payload = {}) => data(apiClient.post(urls.chats, payload)),
10
10
  updateChat: (id, patch) => data(apiClient.patch(urls.chat(id), patch)),
11
11
  deleteChat: (id) => data(apiClient.delete(urls.chat(id))),
12
12
  listTurns: (id, { cursor } = {}) => data(apiClient.get(urls.turns(id), { params: cursor ? { cursor } : {} })),
13
- getStatus: () => data(apiClient.get(urls.status)),
13
+ getStatus: ({ scope } = {}) => data(scope !== undefined ? apiClient.get(urls.status, { params: { scope } }) : apiClient.get(urls.status)),
14
14
  };
15
15
  return Object.assign(Object.assign({}, defaults), overrides);
16
16
  }
@@ -1,9 +1,10 @@
1
1
  import { createKitSxEntry } from '../theme/kitSxEntry';
2
2
  import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from './ChatList';
3
- import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX } from './ChatThread';
3
+ import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX, CHAT_THREAD_TRIMMED_SX } from './ChatThread';
4
4
  export const CHAT_KIT_SX_REGISTRY = [
5
5
  createKitSxEntry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX, 'CHAT_LIST_ROW_ACTION_SX'),
6
6
  createKitSxEntry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX, 'CHAT_LIST_ARCHIVED_ACTION_SX'),
7
7
  createKitSxEntry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX, 'CHAT_THREAD_TOOL_CHIP_SX'),
8
+ createKitSxEntry('ChatThread.trimmed', 'MuiChip', CHAT_THREAD_TRIMMED_SX, 'CHAT_THREAD_TRIMMED_SX'),
8
9
  createKitSxEntry('ChatThread.notice', 'MuiAlert', CHAT_THREAD_NOTICE_SX, 'CHAT_THREAD_NOTICE_SX'),
9
10
  ];
package/dist/chat/wire.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export const CHAT_CLOSE_CODES = { 4401: 'session_ended', 4403: 'access_lost', 4429: 'rate_limited' };
2
- export const CHAT_ERROR_CODES = ['quota_exceeded', 'provider_error', 'timeout', 'prompt_too_long', 'invalid_output', 'security_violation', 'not_configured', 'rate_limited', 'invalid_selection', 'archived', 'turn_active', 'invalid_frame'];
2
+ export const CHAT_ERROR_CODES = ['quota_exceeded', 'provider_error', 'timeout', 'prompt_too_long', 'invalid_output', 'security_violation', 'not_configured', 'rate_limited', 'invalid_selection', 'archived', 'turn_active', 'invalid_frame', 'unavailable'];
3
3
  export const chatApiUrls = (base = '/api/assistant/') => {
4
4
  const root = base.replace(/\/$/, '') + '/';
5
5
  return { catalogue: root + 'catalogue/', chats: root + 'chats/', chat: (id) => root + 'chats/' + encodeURIComponent(id) + '/', turns: (id) => root + 'chats/' + encodeURIComponent(id) + '/turns/', status: root + 'status/' };
6
6
  };
7
7
  export const defaultChatWsUrl = (chatId) => (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws/assistant/chats/' + encodeURIComponent(chatId) + '/';
8
+ export const CHAT_FRAME_TYPES = { APP: 'app', NOTICE: 'notice' };
9
+ export const CHAT_NOTICE_CODES = { HISTORY_TRIMMED: 'history_trimmed' };
@@ -33,6 +33,10 @@ export const chatTranslations = {
33
33
  'Chat.changeModel': { de: 'Modell ändern', en: 'Change model', fr: 'Changer de modèle', sw: 'Badilisha modeli' },
34
34
  'Chat.usageLimit': { de: 'Nutzungslimit für {{model}} erreicht. Wählen Sie ein anderes Modell oder versuchen Sie es später erneut.', en: 'Usage limit reached for {{model}}. Choose another model or try again later.', fr: 'Limite d’utilisation atteinte pour {{model}}. Choisissez un autre modèle ou réessayez plus tard.', sw: 'Kikomo cha matumizi kimefikiwa kwa {{model}}. Chagua modeli nyingine au jaribu tena baadaye.' },
35
35
  'Chat.answerIncomplete': { de: 'Die Antwort konnte nicht abgeschlossen werden.', en: 'The answer could not be completed.', fr: 'La réponse n’a pas pu être terminée.', sw: 'Jibu halikuweza kukamilishwa.' },
36
+ 'Chat.notAvailableHere': { de: 'Der Assistent ist hier nicht verfügbar.', en: 'The assistant is not available here.', fr: 'L’assistant n’est pas disponible ici.', sw: 'Msaidizi hapatikani hapa.' },
37
+ 'Chat.historyTrimmed_one': { de: '{{count}} frühere Nachricht wurde ausgelassen', en: '{{count}} earlier message left out', fr: '{{count}} message précédent omis', sw: 'Ujumbe {{count}} wa awali umeachwa' },
38
+ 'Chat.historyTrimmed_other': { de: '{{count}} frühere Nachrichten wurden ausgelassen', en: '{{count}} earlier messages left out', fr: '{{count}} messages précédents omis', sw: 'Ujumbe {{count}} wa awali umeachwa' },
39
+ 'Chat.sidePanel': { de: 'Details', en: 'Details', fr: 'Détails', sw: 'Maelezo' },
36
40
  'Chat.unavailable': { de: 'Chat ist nicht verfügbar', en: 'Chat is not available', fr: 'Le chat n’est pas disponible', sw: 'Mazungumzo hayapatikani' },
37
41
  'Chat.noModel': { de: 'Auf diesem Server ist kein Sprachmodell konfiguriert.', en: 'No language model is configured on this server.', fr: 'Aucun modèle de langage n’est configuré sur ce serveur.', sw: 'Hakuna modeli ya lugha iliyosanidiwa kwenye seva hii.' },
38
42
  'Chat.modelChanged': { de: 'Modell gewechselt: {{model}}', en: 'Model changed: {{model}}', fr: 'Modèle changé : {{model}}', sw: 'Modeli imebadilishwa: {{model}}' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "4.0.1",
3
+ "version": "4.2.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {