@micha.bigler/ui-core-micha 3.13.2 → 3.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/.github/workflows/publish.yml +4 -4
  3. package/CHANGELOG.md +35 -0
  4. package/WORK_ORDERS.md +7 -1
  5. package/dist/auth/UserMenu.js +1 -1
  6. package/dist/auth/apiClient.js +7 -6
  7. package/dist/chat/ChatComposer.js +23 -0
  8. package/dist/chat/ChatList.js +30 -0
  9. package/dist/chat/ChatThread.js +70 -0
  10. package/dist/chat/ChatWorkspace.js +218 -0
  11. package/dist/chat/MarkdownReply.js +21 -0
  12. package/dist/chat/ModelPicker.js +35 -0
  13. package/dist/chat/api.js +17 -0
  14. package/dist/chat/useChatSocket.js +68 -0
  15. package/dist/chat/wire.js +7 -0
  16. package/dist/components/LoginForm.js +1 -1
  17. package/dist/components/SecurityComponent.js +1 -1
  18. package/dist/components/UserListComponent.js +2 -8
  19. package/dist/components/charts/ChartFrame.js +1 -1
  20. package/dist/components/charts/ScatterChart.js +2 -1
  21. package/dist/i18n/chatTranslations.js +55 -0
  22. package/dist/i18n/uiCoreTranslations.js +2 -1
  23. package/dist/index.js +10 -0
  24. package/dist/layout/SectionNav.js +1 -1
  25. package/dist/messaging/AttachmentList.js +2 -2
  26. package/dist/messaging/Composer.js +2 -1
  27. package/dist/messaging/ConversationLaunchers.js +1 -4
  28. package/dist/messaging/ConversationList.js +1 -1
  29. package/dist/messaging/DirectMessageLauncher.js +1 -1
  30. package/dist/messaging/MessagingProvider.js +0 -0
  31. package/dist/messaging/PollCard.js +7 -5
  32. package/dist/messaging/ReadTicks.js +2 -2
  33. package/dist/messaging/Thread.js +5 -3
  34. package/dist/notifications/NotificationBell.js +4 -7
  35. package/dist/notifications/NotificationSettings.js +1 -1
  36. package/dist/onboarding/OnboardingProvider.js +3 -6
  37. package/dist/pages/LoginPage.js +1 -1
  38. package/dist/pages/PasswordChangePage.js +1 -1
  39. package/dist/pages/PasswordInvitePage.js +1 -1
  40. package/dist/pages/PasswordResetRequestPage.js +1 -1
  41. package/dist/pages/SignUpPage.js +1 -1
  42. package/dist/pages/SignupConfirmPage.js +1 -1
  43. package/dist/theme/createAppTheme.js +5 -4
  44. package/dist/theme/kitSxRegistry.js +10 -0
  45. package/dist/theme/themeCompleteness.js +3 -12
  46. package/dist/utils/auth-errors.js +4 -3
  47. package/package.json +13 -11
  48. package/src/chat/ChatComposer.jsx +36 -0
  49. package/src/chat/ChatList.jsx +87 -0
  50. package/src/chat/ChatThread.jsx +128 -0
  51. package/src/chat/ChatWorkspace.jsx +244 -0
  52. package/src/chat/MarkdownReply.jsx +22 -0
  53. package/src/chat/ModelPicker.jsx +85 -0
  54. package/src/chat/api.js +18 -0
  55. package/src/chat/useChatSocket.js +32 -0
  56. package/src/chat/wire.js +7 -0
  57. package/src/i18n/chatTranslations.ts +55 -0
  58. package/src/i18n/uiCoreTranslations.js +2 -0
  59. package/src/index.js +11 -0
  60. package/src/theme/kitSxRegistry.js +10 -0
  61. package/tests/chatApi.test.js +48 -0
  62. package/tests/chatDriverNames.test.jsx +71 -0
  63. package/tests/chatExports.test.js +3 -0
  64. package/tests/chatList.test.jsx +84 -0
  65. package/tests/chatPicker.test.jsx +82 -0
  66. package/tests/chatSocket.test.jsx +26 -0
  67. package/tests/chatThread.test.jsx +116 -0
  68. package/tests/chatTranslations.test.js +47 -0
  69. package/tests/chatWorkspace.test.jsx +234 -0
  70. package/tests/i18nAggregate.test.js +2 -0
  71. package/tsconfig.build.json +1 -1
  72. package/work-orders/UCM-AUTH-13.md +2 -0
  73. package/work-orders/UCM-CHAT-1.md +190 -0
  74. package/work-orders/UCM-CHAT-2.md +75 -0
  75. package/work-orders/UCM-CI-1.md +65 -0
  76. package/work-orders/UCM-DEP-1.md +63 -0
  77. package/work-orders/assets/UCM-CHAT-1-ai-chat.html +447 -0
@@ -0,0 +1,85 @@
1
+ import React, { useMemo, useRef, useState } from 'react';
2
+ import { Box, Button, Chip, Drawer, List, ListItemButton, ListItemText, Popover, Stack, ToggleButton, ToggleButtonGroup, Typography, useMediaQuery, useTheme } from '@mui/material';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
6
+ export const driverName = (t, id) => t(`Chat.driver.${id}`, { defaultValue: id });
7
+
8
+ const groupByDriver = (entries) => entries.reduce((groups, entry) => {
9
+ (groups[entry.driver] = groups[entry.driver] || []).push(entry);
10
+ return groups;
11
+ }, {});
12
+
13
+ /**
14
+ * Model and effort of one chat. A popover at wide widths, a bottom sheet at narrow widths. `onChange` receives
15
+ * `{ entry, effort }`; the caller persists it (the change applies from the next turn). Can be controlled with `open`.
16
+ */
17
+ export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange }) {
18
+ const { t } = useTranslation();
19
+ const theme = useTheme();
20
+ const narrow = useMediaQuery(theme.breakpoints.down('md'));
21
+ const anchor = useRef(null);
22
+ const [innerOpen, setInnerOpen] = useState(false);
23
+ const isOpen = open ?? innerOpen;
24
+ const setOpen = (value) => { setInnerOpen(value); onOpenChange?.(value); };
25
+ const groups = useMemo(() => groupByDriver(entries), [entries]);
26
+ const active = entries.find((entry) => entry.id === selection?.entry);
27
+
28
+ const pickEntry = (entry) => {
29
+ const effort = entry.efforts?.includes(selection?.effort) ? selection.effort : entry.efforts?.[0];
30
+ onChange?.({ entry: entry.id, effort });
31
+ };
32
+
33
+ const content = (
34
+ <Box sx={{ p: 2, minWidth: narrow ? 0 : 320, maxWidth: '100%' }}>
35
+ <Stack spacing={1.5}>
36
+ {Object.entries(groups).map(([driver, items]) => (
37
+ <Box key={driver} role="group" aria-label={driverName(t, driver)}>
38
+ <Typography variant="overline" color="text.secondary">{driverName(t, driver)}</Typography>
39
+ <List dense disablePadding>
40
+ {items.map((entry) => (
41
+ <ListItemButton key={entry.id} selected={entry.id === selection?.entry} onClick={() => pickEntry(entry)} sx={{ minHeight: 40, borderRadius: 1 }}>
42
+ <ListItemText primary={entry.label || entry.model} />
43
+ {entry.billing === 'pay_per_use' ? <Chip size="small" variant="outlined" label={t('Chat.payPerUse')} /> : null}
44
+ </ListItemButton>
45
+ ))}
46
+ </List>
47
+ </Box>
48
+ ))}
49
+ <Box>
50
+ <Typography variant="overline" color="text.secondary">{t('Chat.effortLabel')}</Typography>
51
+ <ToggleButtonGroup
52
+ exclusive
53
+ fullWidth
54
+ size="small"
55
+ aria-label={t('Chat.effortLabel')}
56
+ value={selection?.effort || null}
57
+ onChange={(_event, effort) => { if (effort && active) onChange?.({ entry: active.id, effort }); }}
58
+ >
59
+ {EFFORT_LEVELS.map((effort) => (
60
+ <ToggleButton key={effort} value={effort} disabled={!active?.efforts?.includes(effort)} sx={{ minHeight: 40 }}>
61
+ {t(`Chat.effort.${effort}`)}
62
+ </ToggleButton>
63
+ ))}
64
+ </ToggleButtonGroup>
65
+ </Box>
66
+ </Stack>
67
+ </Box>
68
+ );
69
+
70
+ const label = active ? [active.label || active.model, selection?.effort && t(`Chat.effort.${selection.effort}`)].filter(Boolean).join(' · ') : t('Chat.chooseModel');
71
+ return (
72
+ <>
73
+ <Button ref={anchor} size="small" color="inherit" disabled={disabled || entries.length === 0} onClick={() => setOpen(true)} aria-haspopup="dialog">
74
+ {label}
75
+ </Button>
76
+ {narrow ? (
77
+ <Drawer anchor="bottom" open={isOpen} onClose={() => setOpen(false)}>{content}</Drawer>
78
+ ) : (
79
+ <Popover open={isOpen} anchorEl={anchor.current} onClose={() => setOpen(false)} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}>{content}</Popover>
80
+ )}
81
+ </>
82
+ );
83
+ }
84
+
85
+ export default ModelPicker;
@@ -0,0 +1,18 @@
1
+ import apiClient from '../auth/apiClient';
2
+ import { chatApiUrls } from './wire';
3
+
4
+ export function createChatApi(overrides = {}, { apiBase = '/api/assistant/' } = {}) {
5
+ const urls = chatApiUrls(apiBase);
6
+ const data = (request) => request.then((response) => response.data);
7
+ const defaults = {
8
+ getCatalogue: () => data(apiClient.get(urls.catalogue)),
9
+ listChats: ({ archived = false, cursor } = {}) => data(apiClient.get(urls.chats, { params: { archived, ...(cursor ? { cursor } : {}) } })),
10
+ createChat: (payload = {}) => data(apiClient.post(urls.chats, payload)),
11
+ updateChat: (id, patch) => data(apiClient.patch(urls.chat(id), patch)),
12
+ deleteChat: (id) => data(apiClient.delete(urls.chat(id))),
13
+ listTurns: (id, { cursor } = {}) => data(apiClient.get(urls.turns(id), { params: cursor ? { cursor } : {} })),
14
+ getStatus: () => data(apiClient.get(urls.status)),
15
+ };
16
+ return { ...defaults, ...overrides };
17
+ }
18
+ export const defaultChatApi = createChatApi();
@@ -0,0 +1,32 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { CHAT_CLOSE_CODES, defaultChatWsUrl } from './wire';
3
+
4
+ const delays = [1000, 2000, 4000];
5
+ export function useChatSocket(chatId, { wsUrl = defaultChatWsUrl, onFrame, onOpen, onClose } = {}) {
6
+ const socket = useRef(null); const callbacks = useRef({ onFrame, onOpen, onClose }); callbacks.current = { onFrame, onOpen, onClose };
7
+ // The URL builder is read at connect time only: an inline arrow from the caller must not reopen the socket on every render.
8
+ const urlBuilder = useRef(wsUrl); urlBuilder.current = wsUrl;
9
+ const [status, setStatus] = useState('idle'); const [generation, setGeneration] = useState(0);
10
+ const reconnect = useCallback(() => setGeneration((n) => n + 1), []);
11
+ useEffect(() => {
12
+ if (!chatId || typeof WebSocket === 'undefined') { setStatus('idle'); return undefined; }
13
+ let alive = true; let tries = 0; let timer; let wasOpened = false;
14
+ const connect = () => {
15
+ setStatus('connecting'); const ws = new WebSocket(typeof urlBuilder.current === 'function' ? urlBuilder.current(chatId) : urlBuilder.current); socket.current = ws;
16
+ ws.onopen = () => { if (!alive) return; tries = 0; setStatus('open'); callbacks.current.onOpen?.(chatId, wasOpened); wasOpened = true; };
17
+ ws.onmessage = (event) => { if (!alive) return; try { const frame = JSON.parse(event.data); if (frame && typeof frame === 'object') callbacks.current.onFrame?.(frame, chatId); } catch { /* Ignore malformed frames. */ } };
18
+ ws.onclose = (event) => {
19
+ if (!alive || socket.current !== ws) return; socket.current = null;
20
+ const reason = CHAT_CLOSE_CODES[event.code];
21
+ if (reason) { setStatus('closed'); callbacks.current.onClose?.({ reason, chatId, retrying: false }); return; }
22
+ if (tries < delays.length) { setStatus('connecting'); callbacks.current.onClose?.({ reason: 'connection_lost', chatId, retrying: true }); timer = setTimeout(connect, delays[tries++]); }
23
+ else { setStatus('closed'); callbacks.current.onClose?.({ reason: 'connection_lost', chatId, retrying: false }); }
24
+ };
25
+ };
26
+ connect();
27
+ return () => { alive = false; clearTimeout(timer); const ws = socket.current; socket.current = null; if (ws) { ws.onopen = ws.onmessage = ws.onclose = null; ws.close(); } };
28
+ }, [chatId, generation]);
29
+ const send = useCallback((frame) => { const ws = socket.current; if (!ws || ws.readyState !== WebSocket.OPEN) return false; ws.send(JSON.stringify(frame)); return true; }, []);
30
+ return { status, connected: status === 'open', send, reconnect };
31
+ }
32
+ export default useChatSocket;
@@ -0,0 +1,7 @@
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'];
3
+ export const chatApiUrls = (base = '/api/assistant/') => {
4
+ const root = base.replace(/\/$/, '') + '/';
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
+ };
7
+ export const defaultChatWsUrl = (chatId) => (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws/assistant/chats/' + encodeURIComponent(chatId) + '/';
@@ -0,0 +1,55 @@
1
+ export const chatTranslations = {
2
+ 'Chat.newChat': { de: 'Neuer Chat', en: 'New chat', fr: 'Nouveau chat', sw: 'Mazungumzo mapya' },
3
+ 'Chat.chats': { de: 'Chats', en: 'Chats', fr: 'Discussions', sw: 'Mazungumzo' },
4
+ 'Chat.archived': { de: 'Archiviert', en: 'Archived', fr: 'Archivé', sw: 'Imewekwa kwenye kumbukumbu' },
5
+ 'Chat.restore': { de: 'Wiederherstellen', en: 'Restore', fr: 'Restaurer', sw: 'Rejesha' },
6
+ 'Chat.rename': { de: 'Umbenennen', en: 'Rename', fr: 'Renommer', sw: 'Badilisha jina' },
7
+ 'Chat.archive': { de: 'Archivieren', en: 'Archive', fr: 'Archiver', sw: 'Weka kwenye kumbukumbu' },
8
+ 'Chat.delete': { de: 'Löschen', en: 'Delete', fr: 'Supprimer', sw: 'Futa' },
9
+ 'Chat.deleteConfirm': { de: 'Möchten Sie diesen Chat wirklich löschen?', en: 'Delete this chat?', fr: 'Supprimer cette discussion ?', sw: 'Futa mazungumzo haya?' },
10
+ 'Chat.cancel': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler', sw: 'Ghairi' },
11
+ 'Chat.save': { de: 'Speichern', en: 'Save', fr: 'Enregistrer', sw: 'Hifadhi' },
12
+ 'Chat.actions': { de: 'Chat-Aktionen', en: 'Chat actions', fr: 'Actions de discussion', sw: 'Vitendo vya mazungumzo' },
13
+ 'Chat.loadMore': { de: 'Mehr laden', en: 'Load more', fr: 'Charger plus', sw: 'Pakia zaidi' },
14
+ 'Chat.loadOlder': { de: 'Ältere laden', en: 'Load older', fr: 'Charger les plus anciens', sw: 'Pakia za zamani' },
15
+ 'Chat.send': { de: 'Senden', en: 'Send', fr: 'Envoyer', sw: 'Tuma' },
16
+ 'Chat.stop': { de: 'Stoppen', en: 'Stop', fr: 'Arrêter', sw: 'Simamisha' },
17
+ 'Chat.message': { de: 'Nachricht', en: 'Message', fr: 'Message', sw: 'Ujumbe' },
18
+ 'Chat.typing': { de: 'Antwort wird erstellt …', en: 'Thinking…', fr: 'Réflexion…', sw: 'Inafikiria…' },
19
+ 'Chat.retry': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer', sw: 'Jaribu tena' },
20
+ 'Chat.interrupted': { de: 'Unterbrochen', en: 'Interrupted', fr: 'Interrompue', sw: 'Imekatizwa' },
21
+ 'Chat.reconnecting': { de: 'Verbindung wird wiederhergestellt', en: 'Reconnecting', fr: 'Reconnexion', sw: 'Inaunganisha tena' },
22
+ 'Chat.payPerUse': { de: 'Nutzungsabhängig', en: 'pay per use', fr: 'à l’usage', sw: 'lipa kwa matumizi' },
23
+ 'Chat.chooseModel': { de: 'Modell wählen', en: 'Choose model', fr: 'Choisir un modèle', sw: 'Chagua modeli' },
24
+ 'Chat.effort.low': { de: 'Niedrig', en: 'Low', fr: 'Faible', sw: 'Chini' },
25
+ 'Chat.effort.medium': { de: 'Mittel', en: 'Medium', fr: 'Moyen', sw: 'Wastani' },
26
+ 'Chat.effort.high': { de: 'Hoch', en: 'High', fr: 'Élevé', sw: 'Juu' },
27
+ 'Chat.effort.max': { de: 'Maximum', en: 'Max', fr: 'Maximal', sw: 'Juu zaidi' },
28
+ 'Chat.driver.claude_cli': { de: 'Claude Code (Abo)', en: 'Claude Code subscription', fr: 'Claude Code (abonnement)', sw: 'Claude Code (usajili)' },
29
+ 'Chat.driver.anthropic_api': { de: 'Anthropic API', en: 'Anthropic API', fr: 'API Anthropic', sw: 'API ya Anthropic' },
30
+ 'Chat.driver.openai_api': { de: 'OpenAI API', en: 'OpenAI API', fr: 'API OpenAI', sw: 'API ya OpenAI' },
31
+ 'Chat.markdownTable': { de: 'Tabelleninhalt', en: 'Table content', fr: 'Contenu du tableau', sw: 'Yaliyomo kwenye jedwali' },
32
+ 'Chat.markdownCode': { de: 'Codeblock', en: 'Code block', fr: 'Bloc de code', sw: 'Kizuizi cha msimbo' },
33
+ 'Chat.changeModel': { de: 'Modell ändern', en: 'Change model', fr: 'Changer de modèle', sw: 'Badilisha modeli' },
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
+ '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.unavailable': { de: 'Chat ist nicht verfügbar', en: 'Chat is not available', fr: 'Le chat n’est pas disponible', sw: 'Mazungumzo hayapatikani' },
37
+ '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
+ 'Chat.modelChanged': { de: 'Modell gewechselt: {{model}}', en: 'Model changed: {{model}}', fr: 'Modèle changé : {{model}}', sw: 'Modeli imebadilishwa: {{model}}' },
39
+ 'Chat.connectionLost': { de: 'Verbindung verloren', en: 'Connection lost', fr: 'Connexion perdue', sw: 'Muunganisho umepotea' },
40
+ 'Chat.reconnect': { de: 'Erneut verbinden', en: 'Reconnect', fr: 'Se reconnecter', sw: 'Unganisha tena' },
41
+ 'Chat.sessionEnded': { de: 'Ihre Sitzung ist beendet. Bitte melden Sie sich erneut an.', en: 'Your session has ended. Please sign in again.', fr: 'Votre session a pris fin. Veuillez vous reconnecter.', sw: 'Kipindi chako kimeisha. Tafadhali ingia tena.' },
42
+ 'Chat.accessLost': { de: 'Sie haben keinen Zugriff mehr auf diesen Chat.', en: 'You no longer have access to this chat.', fr: 'Vous n’avez plus accès à cette discussion.', sw: 'Huna tena ufikiaji wa mazungumzo haya.' },
43
+ 'Chat.rateLimited': { de: 'Zu viele Anfragen. Bitte einen Moment warten.', en: 'Too many requests. Please wait a moment.', fr: 'Trop de requêtes. Veuillez patienter un instant.', sw: 'Maombi mengi mno. Tafadhali subiri kidogo.' },
44
+ 'Chat.untitled': { de: 'Neuer Chat', en: 'New chat', fr: 'Nouvelle discussion', sw: 'Mazungumzo mapya' },
45
+ 'Chat.emptyTitle': { de: 'Wie kann ich helfen?', en: 'How can I help?', fr: 'Comment puis-je aider ?', sw: 'Naweza kusaidiaje?' },
46
+ 'Chat.selectChat': { de: 'Wählen Sie einen Chat oder starten Sie einen neuen.', en: 'Select a chat or start a new one.', fr: 'Sélectionnez une discussion ou commencez-en une nouvelle.', sw: 'Chagua mazungumzo au anza mapya.' },
47
+ 'Chat.archivedReadOnly': { de: 'Archivierter Chat. Stellen Sie ihn wieder her, um weiterzuschreiben.', en: 'Archived chat. Restore it to continue.', fr: 'Discussion archivée. Restaurez-la pour continuer.', sw: 'Mazungumzo yaliyowekwa kwenye kumbukumbu. Yarejeshe ili kuendelea.' },
48
+ 'Chat.loadError': { de: 'Chats konnten nicht geladen werden.', en: 'Chats could not be loaded.', fr: 'Impossible de charger les discussions.', sw: 'Mazungumzo hayakuweza kupakiwa.' },
49
+ 'Chat.actionError': { de: 'Aktion fehlgeschlagen.', en: 'Action failed.', fr: 'Action impossible.', sw: 'Kitendo kimeshindwa.' },
50
+ 'Chat.sources': { de: 'Quellen', en: 'Sources', fr: 'Sources', sw: 'Vyanzo' },
51
+ 'Chat.model': { de: 'Modell', en: 'Model', fr: 'Modèle', sw: 'Modeli' },
52
+ 'Chat.effortLabel': { de: 'Aufwand', en: 'Effort', fr: 'Effort', sw: 'Juhudi' },
53
+ 'Chat.renameLabel': { de: 'Chat-Titel', en: 'Chat title', fr: 'Titre de la discussion', sw: 'Kichwa cha mazungumzo' },
54
+ 'Chat.thread': { de: 'Unterhaltung', en: 'Conversation', fr: 'Conversation', sw: 'Mazungumzo' },
55
+ };
@@ -1,5 +1,6 @@
1
1
  import { authTranslations } from './authTranslations';
2
2
  import { chartsTranslations } from './chartsTranslations';
3
+ import { chatTranslations } from './chatTranslations';
3
4
  import { messagingTranslations } from './messagingTranslations';
4
5
  import { notificationsTranslations } from './notificationsTranslations';
5
6
  import { onboardingTranslations } from './onboardingTranslations';
@@ -9,6 +10,7 @@ import { userMenuTranslations } from './userMenuTranslations';
9
10
  export const uiCoreTranslations = {
10
11
  ...authTranslations,
11
12
  ...chartsTranslations,
13
+ ...chatTranslations,
12
14
  ...messagingTranslations,
13
15
  ...notificationsTranslations,
14
16
  ...onboardingTranslations,
package/src/index.js CHANGED
@@ -124,6 +124,16 @@ export { ReactionBar } from './messaging/ReactionBar';
124
124
  export { PollCard } from './messaging/PollCard';
125
125
  export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
126
126
 
127
+ // --- Shared AI chat ---
128
+ export { ChatWorkspace } from './chat/ChatWorkspace';
129
+ export { ChatList } from './chat/ChatList';
130
+ export { ChatThread } from './chat/ChatThread';
131
+ export { ChatComposer } from './chat/ChatComposer';
132
+ export { ModelPicker } from './chat/ModelPicker';
133
+ export { default as MarkdownReply } from './chat/MarkdownReply';
134
+ export { useChatSocket } from './chat/useChatSocket';
135
+ export { createChatApi, defaultChatApi } from './chat/api';
136
+
127
137
  // --- 9. Onboarding ---
128
138
  export * from './onboarding/api';
129
139
  export { selectActiveSteps } from './onboarding/stepSelection';
@@ -144,4 +154,5 @@ export { notificationsTranslations } from './i18n/notificationsTranslations';
144
154
  export { onboardingTranslations } from './i18n/onboardingTranslations';
145
155
  export { chartsTranslations } from './i18n/chartsTranslations';
146
156
  export { messagingTranslations } from './i18n/messagingTranslations';
157
+ export { chatTranslations } from './i18n/chatTranslations';
147
158
  export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
@@ -97,6 +97,8 @@ import { LOGIN_PAGE_ALERT_SX } from '../pages/LoginPage';
97
97
  import { SIGN_UP_PAGE_ALERT_SX } from '../pages/SignUpPage';
98
98
  import { SIGNUP_CONFIRM_PAGE_ALERT_SX } from '../pages/SignupConfirmPage';
99
99
  import { REAUTH_MODAL_ALERT_SX, REAUTH_MODAL_TEXT_FIELD_SX } from '../auth/ReauthModal';
100
+ import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from '../chat/ChatList';
101
+ import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX } from '../chat/ChatThread';
100
102
 
101
103
  const SX_EXPORTS = {
102
104
  ACCESS_CODE_ACTION_SX,
@@ -168,6 +170,10 @@ const SX_EXPORTS = {
168
170
  SUPPORT_RECOVERY_AGENT_PAPER_SX,
169
171
  SUPPORT_RECOVERY_ALERT_SX,
170
172
  THREAD_DAY_SEPARATOR_SX,
173
+ CHAT_LIST_ARCHIVED_ACTION_SX,
174
+ CHAT_LIST_ROW_ACTION_SX,
175
+ CHAT_THREAD_NOTICE_SX,
176
+ CHAT_THREAD_TOOL_CHIP_SX,
171
177
  USER_INVITE_ACTION_SX,
172
178
  USER_INVITE_ALERT_SX,
173
179
  USER_LIST_ACTION_SX,
@@ -272,4 +278,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
272
278
  entry('SignupConfirmPage.alert', 'MuiAlert', SIGNUP_CONFIRM_PAGE_ALERT_SX),
273
279
  entry('ReauthModal.alert', 'MuiAlert', REAUTH_MODAL_ALERT_SX),
274
280
  entry('ReauthModal.field', 'MuiTextField', REAUTH_MODAL_TEXT_FIELD_SX),
281
+ entry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX),
282
+ entry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX),
283
+ entry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX),
284
+ entry('ChatThread.notice', 'MuiAlert', CHAT_THREAD_NOTICE_SX),
275
285
  ];
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../src/auth/apiClient', () => ({
4
+ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
5
+ }));
6
+
7
+ import apiClient from '../src/auth/apiClient';
8
+ import { createChatApi } from '../src/chat/api';
9
+
10
+ beforeEach(() => {
11
+ for (const method of ['get', 'post', 'patch', 'delete']) apiClient[method].mockReset().mockResolvedValue({ data: { ok: method } });
12
+ });
13
+
14
+ describe('chat api client', () => {
15
+ it('calls the contract endpoints and unwraps the response data', async () => {
16
+ const api = createChatApi();
17
+ expect(await api.getCatalogue()).toEqual({ ok: 'get' });
18
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/catalogue/');
19
+ await api.listChats({ archived: true, cursor: 'c1' });
20
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/chats/', { params: { archived: true, cursor: 'c1' } });
21
+ await api.createChat({ scope: 'x' });
22
+ expect(apiClient.post).toHaveBeenLastCalledWith('/api/assistant/chats/', { scope: 'x' });
23
+ await api.updateChat(4, { selection: { entry: 'a', effort: 'low' } });
24
+ expect(apiClient.patch).toHaveBeenLastCalledWith('/api/assistant/chats/4/', { selection: { entry: 'a', effort: 'low' } });
25
+ await api.deleteChat(4);
26
+ expect(apiClient.delete).toHaveBeenLastCalledWith('/api/assistant/chats/4/');
27
+ await api.listTurns(4, { cursor: 'older' });
28
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/chats/4/turns/', { params: { cursor: 'older' } });
29
+ await api.getStatus();
30
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/status/');
31
+ });
32
+
33
+ it('keeps the defaults of every other function when one is overridden', async () => {
34
+ const names = ['getCatalogue', 'listChats', 'createChat', 'updateChat', 'deleteChat', 'listTurns', 'getStatus'];
35
+ const defaults = createChatApi({}, { apiBase: '/v2/chat' });
36
+ for (const name of names) {
37
+ const override = vi.fn();
38
+ const api = createChatApi({ [name]: override }, { apiBase: '/v2/chat' });
39
+ expect(api[name]).toBe(override);
40
+ for (const other of names.filter((n) => n !== name)) expect(String(api[other])).toBe(String(defaults[other]));
41
+ }
42
+ const api = createChatApi({ getStatus: vi.fn() }, { apiBase: '/v2/chat' });
43
+ await api.getCatalogue();
44
+ expect(apiClient.get).toHaveBeenLastCalledWith('/v2/chat/catalogue/');
45
+ await api.deleteChat(9);
46
+ expect(apiClient.delete).toHaveBeenLastCalledWith('/v2/chat/chats/9/');
47
+ });
48
+ });
@@ -0,0 +1,71 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+ import i18next from 'i18next';
6
+ import { I18nextProvider } from 'react-i18next';
7
+
8
+ import ModelPicker from '../src/chat/ModelPicker';
9
+ import { chatTranslations } from '../src/i18n/chatTranslations';
10
+
11
+ // The real translations through a real i18next instance (the picker tests with a mocked t only see keys).
12
+ function makeI18n(language) {
13
+ const resources = {};
14
+ for (const [key, value] of Object.entries(chatTranslations)) {
15
+ for (const [locale, text] of Object.entries(value)) {
16
+ resources[locale] = resources[locale] || { translation: {} };
17
+ resources[locale].translation[key] = text;
18
+ }
19
+ }
20
+ const instance = i18next.createInstance();
21
+ instance.init({ lng: language, fallbackLng: 'en', resources, keySeparator: false, nsSeparator: false, interpolation: { escapeValue: false } });
22
+ return instance;
23
+ }
24
+
25
+ const entries = [
26
+ { id: 'cli', driver: 'claude_cli', model: 'sonnet', label: 'Sonnet', efforts: ['high'], billing: 'subscription' },
27
+ { id: 'anth', driver: 'anthropic_api', model: 'claude', label: 'Claude API', efforts: ['high'], billing: 'pay_per_use' },
28
+ { id: 'oa', driver: 'openai_api', model: 'gpt', label: 'GPT', efforts: ['high'], billing: 'pay_per_use' },
29
+ { id: 'x', driver: 'mystery_driver', model: 'x', label: 'Mystery', efforts: ['high'], billing: 'subscription' },
30
+ ];
31
+
32
+ function open(language) {
33
+ render(
34
+ <I18nextProvider i18n={makeI18n(language)}>
35
+ <ModelPicker entries={entries} selection={{ entry: 'cli', effort: 'high' }} />
36
+ </I18nextProvider>,
37
+ );
38
+ const bar = screen.getByRole('button', { name: /Sonnet/ });
39
+ const label = bar.textContent;
40
+ fireEvent.click(bar);
41
+ return label;
42
+ }
43
+
44
+ afterEach(cleanup);
45
+
46
+ describe('ModelPicker with the real chat translations', () => {
47
+ it('shows readable English driver names as heading and accessible name, and the raw id for an unknown driver', () => {
48
+ open('en');
49
+ for (const name of ['Claude Code subscription', 'Anthropic API', 'OpenAI API', 'mystery_driver']) {
50
+ expect(screen.getByRole('group', { name })).toBeTruthy();
51
+ expect(screen.getAllByText(name).length).toBeGreaterThan(0);
52
+ }
53
+ expect(screen.queryByRole('group', { name: 'claude_cli' })).toBeNull();
54
+ });
55
+
56
+ it('shows the German names and translates the effort in the bar label', () => {
57
+ const label = open('de');
58
+ expect(label).toBe('Sonnet · Hoch');
59
+ expect(screen.getByRole('group', { name: 'Claude Code (Abo)' })).toBeTruthy();
60
+ });
61
+
62
+ it('shows the French and Swahili names', () => {
63
+ open('fr');
64
+ expect(screen.getByRole('group', { name: 'API Anthropic' })).toBeTruthy();
65
+ expect(screen.getByRole('group', { name: 'Claude Code (abonnement)' })).toBeTruthy();
66
+ cleanup();
67
+ open('sw');
68
+ expect(screen.getByRole('group', { name: 'API ya OpenAI' })).toBeTruthy();
69
+ expect(screen.getByRole('group', { name: 'Claude Code (usajili)' })).toBeTruthy();
70
+ });
71
+ });
@@ -0,0 +1,3 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ChatWorkspace, ChatList, ChatThread, ChatComposer, ModelPicker, MarkdownReply, useChatSocket, createChatApi, defaultChatApi, chatTranslations } from '../src/index';
3
+ describe('chat exports', () => { it('exports the public chat surface', () => { for (const value of [ChatWorkspace, ChatList, ChatThread, ChatComposer, ModelPicker, MarkdownReply, useChatSocket, createChatApi, defaultChatApi, chatTranslations]) expect(value).toBeDefined(); }); });
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
7
+ vi.mock('@mui/icons-material', () => ({ MoreVert: () => null }));
8
+
9
+ import ChatList from '../src/chat/ChatList';
10
+
11
+ afterEach(cleanup);
12
+
13
+ const chats = [{ id: 1, title: 'Budget' }, { id: 2, title: 'Ideas' }];
14
+ const archivedChats = [{ id: 3, title: 'Old notes', archived_at: '2026-10-01T00:00:00Z' }];
15
+
16
+ function setup(props = {}) {
17
+ const handlers = { onSelect: vi.fn(), onNew: vi.fn(), onRename: vi.fn(), onArchive: vi.fn(), onRestore: vi.fn(), onDelete: vi.fn() };
18
+ render(<ChatList chats={chats} archivedChats={archivedChats} {...handlers} {...props} />);
19
+ return handlers;
20
+ }
21
+ const rowMenu = (index) => fireEvent.click(screen.getAllByRole('button', { name: 'Chat.actions' })[index]);
22
+
23
+ describe('ChatList', () => {
24
+ it('selects and creates chats', () => {
25
+ const { onSelect, onNew } = setup();
26
+ fireEvent.click(screen.getByText('Ideas'));
27
+ expect(onSelect).toHaveBeenCalledWith(chats[1]);
28
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.newChat' }));
29
+ expect(onNew).toHaveBeenCalled();
30
+ });
31
+
32
+ it('renames through a dialog', () => {
33
+ const { onRename } = setup();
34
+ rowMenu(0);
35
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.rename' }));
36
+ fireEvent.change(screen.getByRole('textbox', { name: 'Chat.renameLabel' }), { target: { value: ' Budget 2027 ' } });
37
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.save' }));
38
+ expect(onRename).toHaveBeenCalledWith(chats[0], 'Budget 2027');
39
+ });
40
+
41
+ it('archives from the row menu', () => {
42
+ const { onArchive } = setup();
43
+ rowMenu(1);
44
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.archive' }));
45
+ expect(onArchive).toHaveBeenCalledWith(chats[1]);
46
+ });
47
+
48
+ it('asks for confirmation before deleting and does nothing on cancel', () => {
49
+ const { onDelete } = setup();
50
+ rowMenu(0);
51
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.delete' }));
52
+ expect(onDelete).not.toHaveBeenCalled();
53
+ expect(screen.getByText('Chat.deleteConfirm')).toBeTruthy();
54
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.cancel' }));
55
+ expect(onDelete).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('deletes after the confirmation', () => {
59
+ const { onDelete } = setup();
60
+ rowMenu(0);
61
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.delete' }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.delete' }));
63
+ expect(onDelete).toHaveBeenCalledWith(chats[0]);
64
+ });
65
+
66
+ it('hides archived chats behind the Archived entry with its count and restores from there', () => {
67
+ const { onRestore } = setup();
68
+ expect(screen.queryByText('Old notes')).toBeNull();
69
+ const toggle = screen.getByRole('button', { name: /Chat.archived/ });
70
+ expect(toggle.textContent).toContain('1');
71
+ fireEvent.click(toggle);
72
+ expect(screen.getByText('Old notes')).toBeTruthy();
73
+ rowMenu(2);
74
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.restore' }));
75
+ expect(onRestore).toHaveBeenCalledWith(archivedChats[0]);
76
+ });
77
+
78
+ it('offers more chats by cursor', () => {
79
+ const onLoadMore = vi.fn();
80
+ setup({ hasMore: true, onLoadMore });
81
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.loadMore' }));
82
+ expect(onLoadMore).toHaveBeenCalled();
83
+ });
84
+ });
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, values) => key.startsWith('Chat.driver.') && values?.defaultValue && !['Chat.driver.claude_cli', 'Chat.driver.anthropic_api', 'Chat.driver.openai_api'].includes(key) ? values.defaultValue : key }) }));
7
+
8
+ import ModelPicker from '../src/chat/ModelPicker';
9
+
10
+ afterEach(() => { cleanup(); delete window.matchMedia; });
11
+
12
+ const entries = [
13
+ { id: 'cli-sonnet', driver: 'claude_cli', model: 'sonnet', label: 'Sonnet', efforts: ['low', 'medium', 'high'], billing: 'subscription' },
14
+ { id: 'cli-opus', driver: 'claude_cli', model: 'opus', label: 'Opus', efforts: ['high', 'max'], billing: 'subscription' },
15
+ { id: 'anthropic', driver: 'anthropic_api', model: 'claude', label: 'Claude API', efforts: ['medium'], billing: 'pay_per_use' },
16
+ { id: 'oa-5', driver: 'openai_api', model: 'gpt', label: 'GPT', efforts: ['medium'], billing: 'pay_per_use' },
17
+ { id: 'mystery', driver: 'mystery_driver', model: 'mystery', label: 'Mystery', efforts: ['low'], billing: 'subscription' },
18
+ ];
19
+ const selection = { entry: 'cli-sonnet', effort: 'high' };
20
+ const open = () => fireEvent.click(screen.getByRole('button', { name: 'Sonnet · Chat.effort.high' }));
21
+
22
+ describe('ModelPicker', () => {
23
+ it('shows the current selection as "model · effort" on the bar', () => {
24
+ render(<ModelPicker entries={entries} selection={selection} />);
25
+ expect(screen.getByRole('button', { name: 'Sonnet · Chat.effort.high' })).toBeTruthy();
26
+ });
27
+
28
+ it('groups entries by driver and tags pay-per-use entries', () => {
29
+ render(<ModelPicker entries={entries} selection={selection} />);
30
+ open();
31
+ const cli = screen.getByRole('group', { name: 'Chat.driver.claude_cli' });
32
+ expect(within(cli).getByText('Chat.driver.claude_cli')).toBeTruthy();
33
+ expect(within(cli).getByText('Sonnet')).toBeTruthy();
34
+ expect(within(cli).getByText('Opus')).toBeTruthy();
35
+ const anthropic = screen.getByRole('group', { name: 'Chat.driver.anthropic_api' });
36
+ expect(within(anthropic).getByText('Chat.driver.anthropic_api')).toBeTruthy();
37
+ const openai = screen.getByRole('group', { name: 'Chat.driver.openai_api' });
38
+ expect(within(openai).getByText('Chat.driver.openai_api')).toBeTruthy();
39
+ expect(within(openai).getByText('GPT')).toBeTruthy();
40
+ expect(within(openai).getByText('Chat.payPerUse')).toBeTruthy();
41
+ expect(within(cli).queryByText('Chat.payPerUse')).toBeNull();
42
+ const unknown = screen.getByRole('group', { name: 'mystery_driver' });
43
+ expect(within(unknown).getByText('mystery_driver')).toBeTruthy();
44
+ });
45
+
46
+ it('disables effort levels the selected entry does not allow', () => {
47
+ render(<ModelPicker entries={entries} selection={selection} />);
48
+ open();
49
+ expect(screen.getByRole('button', { name: 'Chat.effort.max' }).disabled).toBe(true);
50
+ expect(screen.getByRole('button', { name: 'Chat.effort.low' }).disabled).toBe(false);
51
+ });
52
+
53
+ it('reports a new effort for the same entry', () => {
54
+ const onChange = vi.fn();
55
+ render(<ModelPicker entries={entries} selection={selection} onChange={onChange} />);
56
+ open();
57
+ fireEvent.click(screen.getByRole('button', { name: 'Chat.effort.low' }));
58
+ expect(onChange).toHaveBeenCalledWith({ entry: 'cli-sonnet', effort: 'low' });
59
+ });
60
+
61
+ it('keeps the effort when the new entry allows it and falls back to its first level otherwise', () => {
62
+ const onChange = vi.fn();
63
+ render(<ModelPicker entries={entries} selection={selection} onChange={onChange} />);
64
+ open();
65
+ fireEvent.click(screen.getByText('Opus'));
66
+ expect(onChange).toHaveBeenLastCalledWith({ entry: 'cli-opus', effort: 'high' });
67
+ fireEvent.click(screen.getByText('GPT'));
68
+ expect(onChange).toHaveBeenLastCalledWith({ entry: 'oa-5', effort: 'medium' });
69
+ });
70
+
71
+ it('opens as a bottom sheet at narrow widths', () => {
72
+ window.matchMedia = vi.fn().mockImplementation((query) => ({ matches: true, media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }));
73
+ const { baseElement } = render(<ModelPicker entries={entries} selection={selection} />);
74
+ open();
75
+ expect(baseElement.querySelector('.MuiDrawer-paperAnchorBottom')).toBeTruthy();
76
+ });
77
+
78
+ it('shows no trailing separator when the selection has no effort', () => {
79
+ render(<ModelPicker entries={entries} selection={{ entry: 'cli-sonnet' }} />);
80
+ expect(screen.getByRole('button', { name: 'Sonnet' })).toBeTruthy();
81
+ });
82
+ });
@@ -0,0 +1,26 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, render, screen } from '@testing-library/react';
5
+ import { useChatSocket } from '../src/chat/useChatSocket';
6
+ class FakeSocket {
7
+ static OPEN = 1; static instances = [];
8
+ constructor(url) { this.url=url; this.readyState=0; this.sent=[]; FakeSocket.instances.push(this); }
9
+ open() { this.readyState=1; this.onopen?.(); }
10
+ message(data) { this.onmessage?.({data:JSON.stringify(data)}); }
11
+ send(data) { this.sent.push(data); }
12
+ close(code=1000) { this.readyState=3; this.onclose?.({code}); }
13
+ }
14
+ function Harness({ onFrame }) { const socket = useChatSocket('a', { wsUrl: () => 'ws://test', onFrame }); return <output>{socket.status}</output>; }
15
+ describe('chat socket', () => {
16
+ afterEach(() => { vi.useRealTimers(); delete globalThis.WebSocket; FakeSocket.instances=[]; });
17
+ it('delivers delta and final turn frames with chat id and reconnects without resending', () => {
18
+ vi.useFakeTimers(); globalThis.WebSocket=FakeSocket; const onFrame=vi.fn(); render(<Harness onFrame={onFrame}/>); const first=FakeSocket.instances[0];
19
+ act(()=>{ first.open(); first.message({type:'delta',text:'hi'}); first.message({type:'turn',turn:{assistant_text:'done',sources:[]}}); first.close(1006); });
20
+ expect(onFrame).toHaveBeenCalledWith({type:'delta',text:'hi'},'a'); expect(onFrame).toHaveBeenCalledWith({type:'turn',turn:{assistant_text:'done',sources:[]}},'a');
21
+ act(()=>vi.advanceTimersByTime(1000)); expect(FakeSocket.instances).toHaveLength(2); expect(first.sent).toEqual([]);
22
+ });
23
+ it.each([4401,4403,4429])('does not reconnect for close code %s', (code) => {
24
+ vi.useFakeTimers(); globalThis.WebSocket=FakeSocket; render(<Harness onFrame={vi.fn()}/>); act(()=>FakeSocket.instances[0].close(code)); act(()=>vi.advanceTimersByTime(10000)); expect(FakeSocket.instances).toHaveLength(1);
25
+ });
26
+ });