@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.
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +4 -4
- package/CHANGELOG.md +35 -0
- package/WORK_ORDERS.md +7 -1
- package/dist/auth/UserMenu.js +1 -1
- package/dist/auth/apiClient.js +7 -6
- package/dist/chat/ChatComposer.js +23 -0
- package/dist/chat/ChatList.js +30 -0
- package/dist/chat/ChatThread.js +70 -0
- package/dist/chat/ChatWorkspace.js +218 -0
- package/dist/chat/MarkdownReply.js +21 -0
- package/dist/chat/ModelPicker.js +35 -0
- package/dist/chat/api.js +17 -0
- package/dist/chat/useChatSocket.js +68 -0
- package/dist/chat/wire.js +7 -0
- package/dist/components/LoginForm.js +1 -1
- package/dist/components/SecurityComponent.js +1 -1
- package/dist/components/UserListComponent.js +2 -8
- package/dist/components/charts/ChartFrame.js +1 -1
- package/dist/components/charts/ScatterChart.js +2 -1
- package/dist/i18n/chatTranslations.js +55 -0
- package/dist/i18n/uiCoreTranslations.js +2 -1
- package/dist/index.js +10 -0
- package/dist/layout/SectionNav.js +1 -1
- package/dist/messaging/AttachmentList.js +2 -2
- package/dist/messaging/Composer.js +2 -1
- package/dist/messaging/ConversationLaunchers.js +1 -4
- package/dist/messaging/ConversationList.js +1 -1
- package/dist/messaging/DirectMessageLauncher.js +1 -1
- package/dist/messaging/MessagingProvider.js +0 -0
- package/dist/messaging/PollCard.js +7 -5
- package/dist/messaging/ReadTicks.js +2 -2
- package/dist/messaging/Thread.js +5 -3
- package/dist/notifications/NotificationBell.js +4 -7
- package/dist/notifications/NotificationSettings.js +1 -1
- package/dist/onboarding/OnboardingProvider.js +3 -6
- package/dist/pages/LoginPage.js +1 -1
- package/dist/pages/PasswordChangePage.js +1 -1
- package/dist/pages/PasswordInvitePage.js +1 -1
- package/dist/pages/PasswordResetRequestPage.js +1 -1
- package/dist/pages/SignUpPage.js +1 -1
- package/dist/pages/SignupConfirmPage.js +1 -1
- package/dist/theme/createAppTheme.js +5 -4
- package/dist/theme/kitSxRegistry.js +10 -0
- package/dist/theme/themeCompleteness.js +3 -12
- package/dist/utils/auth-errors.js +4 -3
- package/package.json +13 -11
- package/src/chat/ChatComposer.jsx +36 -0
- package/src/chat/ChatList.jsx +87 -0
- package/src/chat/ChatThread.jsx +128 -0
- package/src/chat/ChatWorkspace.jsx +244 -0
- package/src/chat/MarkdownReply.jsx +22 -0
- package/src/chat/ModelPicker.jsx +85 -0
- package/src/chat/api.js +18 -0
- package/src/chat/useChatSocket.js +32 -0
- package/src/chat/wire.js +7 -0
- package/src/i18n/chatTranslations.ts +55 -0
- package/src/i18n/uiCoreTranslations.js +2 -0
- package/src/index.js +11 -0
- package/src/theme/kitSxRegistry.js +10 -0
- package/tests/chatApi.test.js +48 -0
- package/tests/chatDriverNames.test.jsx +71 -0
- package/tests/chatExports.test.js +3 -0
- package/tests/chatList.test.jsx +84 -0
- package/tests/chatPicker.test.jsx +82 -0
- package/tests/chatSocket.test.jsx +26 -0
- package/tests/chatThread.test.jsx +116 -0
- package/tests/chatTranslations.test.js +47 -0
- package/tests/chatWorkspace.test.jsx +234 -0
- package/tests/i18nAggregate.test.js +2 -0
- package/tsconfig.build.json +1 -1
- package/work-orders/UCM-AUTH-13.md +2 -0
- package/work-orders/UCM-CHAT-1.md +190 -0
- package/work-orders/UCM-CHAT-2.md +75 -0
- package/work-orders/UCM-CI-1.md +65 -0
- package/work-orders/UCM-DEP-1.md +63 -0
- package/work-orders/assets/UCM-CHAT-1-ai-chat.html +447 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, values) => (values?.model ? `${key}:${values.model}` : key) }) }));
|
|
7
|
+
|
|
8
|
+
import ChatThread from '../src/chat/ChatThread';
|
|
9
|
+
import MarkdownReply from '../src/chat/MarkdownReply';
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
const turn = (extra) => ({ id: 1, seq: 1, user_text: 'Question', assistant_text: 'Answer', state: 'completed', sources: [], driver: 'claude_cli', model: 'Sonnet', effort: 'high', ...extra });
|
|
14
|
+
|
|
15
|
+
describe('MarkdownReply', () => {
|
|
16
|
+
it('renders a list and a table', () => {
|
|
17
|
+
render(<MarkdownReply text={'- first item\n- second item\n\n| A | B |\n|---|---|\n| x | y |'} />);
|
|
18
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
|
19
|
+
expect(screen.getByRole('table')).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('shows raw HTML as text and never creates the element', () => {
|
|
23
|
+
const { container } = render(<MarkdownReply text={'before <script>window.__pwned = 1</script> <b>bold</b> after'} />);
|
|
24
|
+
expect(container.querySelector('script')).toBeNull();
|
|
25
|
+
expect(container.querySelector('b')).toBeNull();
|
|
26
|
+
expect(window.__pwned).toBeUndefined();
|
|
27
|
+
expect(container.textContent).toContain('<script>');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not load an image reference and does not link', () => {
|
|
31
|
+
const { container } = render(<MarkdownReply text={' and [docs](https://docs.example.test)'} />);
|
|
32
|
+
expect(container.querySelector('img')).toBeNull();
|
|
33
|
+
expect(container.querySelector('a')).toBeNull();
|
|
34
|
+
expect(container.textContent).toContain('the chart');
|
|
35
|
+
expect(container.textContent).toContain('docs (https://docs.example.test)');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('ChatThread', () => {
|
|
40
|
+
it('shows source chips with the label and the reference on hover', async () => {
|
|
41
|
+
render(<ChatThread turns={[turn({ sources: [{ label: 'KB-12', reference: 'kb://handbook/12#intro' }] })]} />);
|
|
42
|
+
const chip = screen.getByText('KB-12');
|
|
43
|
+
expect(screen.queryByText('kb://handbook/12#intro')).toBeNull();
|
|
44
|
+
fireEvent.mouseOver(chip);
|
|
45
|
+
expect((await screen.findByRole('tooltip')).textContent).toContain('kb://handbook/12#intro');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks where the model changed between two turns', () => {
|
|
49
|
+
render(<ChatThread turns={[turn(), turn({ id: 2, seq: 2, model: 'Opus', effort: 'max' })]} />);
|
|
50
|
+
expect(screen.getByRole('separator').textContent).toContain('Chat.modelChanged:Opus · Chat.effort.max');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows no divider when the selection is unchanged', () => {
|
|
54
|
+
render(<ChatThread turns={[turn(), turn({ id: 2, seq: 2 })]} />);
|
|
55
|
+
expect(screen.queryByRole('separator')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows a typing indicator, then the tool label, for a generating turn', () => {
|
|
59
|
+
const generating = turn({ id: 2, seq: 2, assistant_text: '', state: 'generating' });
|
|
60
|
+
const { rerender } = render(<ChatThread turns={[generating]} />);
|
|
61
|
+
expect(screen.getByText('Chat.typing')).toBeTruthy();
|
|
62
|
+
rerender(<ChatThread turns={[generating]} tool={{ label: 'Searching the knowledge base' }} />);
|
|
63
|
+
expect(screen.getByText('Searching the knowledge base')).toBeTruthy();
|
|
64
|
+
expect(screen.queryByText('Chat.typing')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('shows Interrupted with Retry and calls onRetry with the turn', () => {
|
|
68
|
+
const onRetry = vi.fn();
|
|
69
|
+
const interrupted = turn({ state: 'interrupted' });
|
|
70
|
+
render(<ChatThread turns={[interrupted]} onRetry={onRetry} />);
|
|
71
|
+
expect(screen.getByText('Chat.interrupted')).toBeTruthy();
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.retry' }));
|
|
73
|
+
expect(onRetry).toHaveBeenCalledWith(interrupted);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows the incomplete-answer alert for a failed turn', () => {
|
|
77
|
+
render(<ChatThread turns={[turn({ state: 'failed', error_code: 'provider_error', assistant_text: '' })]} />);
|
|
78
|
+
expect(screen.getByText('Chat.answerIncomplete')).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders the user message as plain text', () => {
|
|
82
|
+
const { container } = render(<ChatThread turns={[turn({ user_text: '<img src=x onerror=alert(1)>' })]} />);
|
|
83
|
+
expect(container.querySelector('img')).toBeNull();
|
|
84
|
+
expect(container.textContent).toContain('<img src=x');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('offers older turns through the cursor callback', () => {
|
|
88
|
+
const onLoadOlder = vi.fn();
|
|
89
|
+
render(<ChatThread turns={[turn()]} hasOlder onLoadOlder={onLoadOlder} />);
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.loadOlder' }));
|
|
91
|
+
expect(onLoadOlder).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('loads older turns when the thread is scrolled to the top', () => {
|
|
95
|
+
const onLoadOlder = vi.fn();
|
|
96
|
+
render(<ChatThread turns={[turn()]} hasOlder onLoadOlder={onLoadOlder} />);
|
|
97
|
+
const log = screen.getByRole('log');
|
|
98
|
+
fireEvent.scroll(log, { target: { scrollTop: 400 } });
|
|
99
|
+
expect(onLoadOlder).not.toHaveBeenCalled();
|
|
100
|
+
fireEvent.scroll(log, { target: { scrollTop: 0 } });
|
|
101
|
+
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('starts only one older-turns request for rapid scroll events', async () => {
|
|
105
|
+
let release;
|
|
106
|
+
const onLoadOlder = vi.fn(() => new Promise((resolve) => { release = resolve; }));
|
|
107
|
+
render(<ChatThread turns={[turn()]} hasOlder onLoadOlder={onLoadOlder} />);
|
|
108
|
+
const log = screen.getByRole('log');
|
|
109
|
+
fireEvent.scroll(log, { target: { scrollTop: 0 } });
|
|
110
|
+
fireEvent.scroll(log, { target: { scrollTop: 0 } });
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.loadOlder' }));
|
|
112
|
+
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
|
113
|
+
release();
|
|
114
|
+
await waitFor(() => { fireEvent.scroll(log, { target: { scrollTop: 0 } }); expect(onLoadOlder).toHaveBeenCalledTimes(2); });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { chatTranslations } from '../src/i18n/chatTranslations';
|
|
3
|
+
|
|
4
|
+
const LOCALES = ['de', 'en', 'fr', 'sw'];
|
|
5
|
+
|
|
6
|
+
describe('chatTranslations', () => {
|
|
7
|
+
it('has every key in all four languages', () => {
|
|
8
|
+
for (const [key, value] of Object.entries(chatTranslations)) {
|
|
9
|
+
for (const locale of LOCALES) {
|
|
10
|
+
expect(typeof value[locale], `${key}.${locale}`).toBe('string');
|
|
11
|
+
expect(value[locale].trim(), `${key}.${locale}`).not.toBe('');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('uses the Envelope copy verbatim in English', () => {
|
|
17
|
+
expect(chatTranslations['Chat.driver.claude_cli'].en).toBe('Claude Code subscription');
|
|
18
|
+
expect(chatTranslations['Chat.driver.anthropic_api'].en).toBe('Anthropic API');
|
|
19
|
+
expect(chatTranslations['Chat.driver.openai_api'].en).toBe('OpenAI API');
|
|
20
|
+
expect(chatTranslations['Chat.usageLimit'].en).toBe('Usage limit reached for {{model}}. Choose another model or try again later.');
|
|
21
|
+
expect(chatTranslations['Chat.answerIncomplete'].en).toBe('The answer could not be completed.');
|
|
22
|
+
expect(chatTranslations['Chat.unavailable'].en).toBe('Chat is not available');
|
|
23
|
+
expect(chatTranslations['Chat.noModel'].en).toBe('No language model is configured on this server.');
|
|
24
|
+
expect(chatTranslations['Chat.payPerUse'].en).toBe('pay per use');
|
|
25
|
+
expect(chatTranslations['Chat.interrupted'].en).toBe('Interrupted');
|
|
26
|
+
expect(chatTranslations['Chat.reconnecting'].en).toBe('Reconnecting');
|
|
27
|
+
expect(chatTranslations['Chat.archived'].en).toBe('Archived');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('includes non-empty driver names in every locale', () => {
|
|
31
|
+
for (const id of ['claude_cli', 'anthropic_api', 'openai_api']) {
|
|
32
|
+
const value = chatTranslations[`Chat.driver.${id}`];
|
|
33
|
+
expect(value).toBeTruthy();
|
|
34
|
+
for (const locale of LOCALES) expect(value[locale].trim(), `${id}.${locale}`).not.toBe('');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps interpolation placeholders in every language', () => {
|
|
39
|
+
for (const [key, value] of Object.entries(chatTranslations)) {
|
|
40
|
+
const wanted = [...value.en.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort();
|
|
41
|
+
for (const locale of LOCALES) {
|
|
42
|
+
const found = [...value[locale].matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort();
|
|
43
|
+
expect(found, `${key}.${locale}`).toEqual(wanted);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, values) => (values?.model !== undefined ? `${key}:${values.model}` : key) }) }));
|
|
7
|
+
vi.mock('@mui/icons-material', () => ({ MoreVert: () => null }));
|
|
8
|
+
|
|
9
|
+
import ChatWorkspace from '../src/chat/ChatWorkspace';
|
|
10
|
+
|
|
11
|
+
class FakeSocket {
|
|
12
|
+
static OPEN = 1;
|
|
13
|
+
static instances = [];
|
|
14
|
+
constructor(url) { this.url = url; this.readyState = 0; this.sent = []; FakeSocket.instances.push(this); }
|
|
15
|
+
open() { this.readyState = 1; this.onopen?.(); }
|
|
16
|
+
push(frame) { this.onmessage?.({ data: JSON.stringify(frame) }); }
|
|
17
|
+
send(data) { this.sent.push(JSON.parse(data)); }
|
|
18
|
+
close(code = 1000) { this.readyState = 3; this.onclose?.({ code }); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const catalogue = {
|
|
22
|
+
entries: [
|
|
23
|
+
{ id: 'cli-sonnet', driver: 'claude_cli', model: 'sonnet', label: 'Sonnet', efforts: ['low', 'high'], billing: 'subscription' },
|
|
24
|
+
{ id: 'oa', driver: 'openai_api', model: 'gpt', label: 'GPT', efforts: ['medium'], billing: 'pay_per_use' },
|
|
25
|
+
],
|
|
26
|
+
default: { entry: 'cli-sonnet', effort: 'high' },
|
|
27
|
+
};
|
|
28
|
+
const baseChat = { id: 7, title: 'Budget', archived_at: null, selection: { entry: 'cli-sonnet', effort: 'high' } };
|
|
29
|
+
|
|
30
|
+
function makeApi(extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
getStatus: vi.fn().mockResolvedValue({ status: 'available' }),
|
|
33
|
+
getCatalogue: vi.fn().mockResolvedValue(catalogue),
|
|
34
|
+
listChats: vi.fn().mockImplementation(async ({ archived } = {}) => ({ results: archived ? [] : [baseChat], next: null })),
|
|
35
|
+
createChat: vi.fn().mockResolvedValue({ ...baseChat, id: 8, title: '' }),
|
|
36
|
+
updateChat: vi.fn().mockImplementation(async (id, patch) => ({ ...baseChat, id, ...patch })),
|
|
37
|
+
deleteChat: vi.fn().mockResolvedValue(null),
|
|
38
|
+
listTurns: vi.fn().mockResolvedValue({ results: [], next: null }),
|
|
39
|
+
...extra,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function openChat(api) {
|
|
44
|
+
render(<ChatWorkspace api={api} wsUrl={(id) => `ws://test/${id}`} />);
|
|
45
|
+
fireEvent.click(await screen.findByText('Budget'));
|
|
46
|
+
await waitFor(() => expect(FakeSocket.instances.length).toBe(1));
|
|
47
|
+
const socket = FakeSocket.instances[0];
|
|
48
|
+
act(() => socket.open());
|
|
49
|
+
return socket;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => { globalThis.WebSocket = FakeSocket; });
|
|
53
|
+
afterEach(() => { cleanup(); delete globalThis.WebSocket; FakeSocket.instances = []; });
|
|
54
|
+
|
|
55
|
+
describe('ChatWorkspace streaming', () => {
|
|
56
|
+
it('streams deltas into the answer and replaces them with the final turn and its sources', async () => {
|
|
57
|
+
const socket = await openChat(makeApi());
|
|
58
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.message' }), { target: { value: 'Hello' } });
|
|
59
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.send' }));
|
|
60
|
+
expect(socket.sent).toEqual([{ type: 'send', message: 'Hello' }]);
|
|
61
|
+
expect(screen.getByText('Hello')).toBeTruthy();
|
|
62
|
+
expect(screen.getByRole('button', { name: 'Chat.stop' })).toBeTruthy();
|
|
63
|
+
expect(screen.getByRole('button', { name: 'Chat.send' }).disabled).toBe(true);
|
|
64
|
+
|
|
65
|
+
act(() => socket.push({ type: 'tool', phase: 'start', name: 'kb', label: 'Searching the knowledge base' }));
|
|
66
|
+
expect(screen.getByText('Searching the knowledge base')).toBeTruthy();
|
|
67
|
+
act(() => { socket.push({ type: 'delta', text: 'Par' }); socket.push({ type: 'delta', text: 'tial' }); });
|
|
68
|
+
expect(screen.getByText('Partial')).toBeTruthy();
|
|
69
|
+
|
|
70
|
+
act(() => socket.push({ type: 'turn', turn: { id: 40, seq: 1, user_text: 'Hello', assistant_text: 'Final text', state: 'completed', sources: [{ label: 'KB-1', reference: 'kb://1' }], driver: 'claude_cli', model: 'Sonnet', effort: 'high' } }));
|
|
71
|
+
expect(screen.queryByText('Partial')).toBeNull();
|
|
72
|
+
expect(screen.getByText('Final text')).toBeTruthy();
|
|
73
|
+
expect(screen.getByText('KB-1')).toBeTruthy();
|
|
74
|
+
expect(screen.queryByText('Searching the knowledge base')).toBeNull();
|
|
75
|
+
expect(screen.getByRole('button', { name: 'Chat.send' })).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('sends stop over the socket while a turn runs', async () => {
|
|
79
|
+
const socket = await openChat(makeApi());
|
|
80
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.message' }), { target: { value: 'Hello' } });
|
|
81
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.send' }));
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.stop' }));
|
|
83
|
+
expect(socket.sent.at(-1)).toEqual({ type: 'stop' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not resend anything after a reconnect', async () => {
|
|
87
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
88
|
+
try {
|
|
89
|
+
const socket = await openChat(makeApi());
|
|
90
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.message' }), { target: { value: 'Hello' } });
|
|
91
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.send' }));
|
|
92
|
+
act(() => socket.close(1006));
|
|
93
|
+
act(() => { vi.advanceTimersByTime(1000); });
|
|
94
|
+
expect(FakeSocket.instances).toHaveLength(2);
|
|
95
|
+
act(() => FakeSocket.instances[1].open());
|
|
96
|
+
expect(FakeSocket.instances[1].sent).toEqual([]);
|
|
97
|
+
} finally { vi.useRealTimers(); }
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('ChatWorkspace edge states', () => {
|
|
102
|
+
it('shows the usage-limit state with Change model, which opens the picker', async () => {
|
|
103
|
+
const socket = await openChat(makeApi());
|
|
104
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.message' }), { target: { value: 'Hello' } });
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.send' }));
|
|
106
|
+
act(() => socket.push({ type: 'error', code: 'quota_exceeded' }));
|
|
107
|
+
expect(screen.getByText('Chat.usageLimit:Sonnet')).toBeTruthy();
|
|
108
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.changeModel' }));
|
|
109
|
+
expect(screen.getByRole('group', { name: 'Chat.driver.openai_api' })).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('shows an interrupted turn from history with Retry, which sends its text again only on click', async () => {
|
|
113
|
+
const api = makeApi({ listTurns: vi.fn().mockResolvedValue({ results: [{ id: 5, seq: 1, user_text: 'Again please', assistant_text: 'Half', state: 'interrupted', sources: [], driver: 'claude_cli', model: 'Sonnet', effort: 'high' }], next: null }) });
|
|
114
|
+
const socket = await openChat(api);
|
|
115
|
+
await screen.findByText('Chat.interrupted');
|
|
116
|
+
expect(socket.sent).toEqual([]);
|
|
117
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.retry' }));
|
|
118
|
+
expect(socket.sent).toEqual([{ type: 'send', message: 'Again please' }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('shows Reconnecting while the connection is being re-established and blocks Send', async () => {
|
|
122
|
+
const socket = await openChat(makeApi());
|
|
123
|
+
act(() => socket.close(1006));
|
|
124
|
+
expect(screen.getByText('Chat.reconnecting')).toBeTruthy();
|
|
125
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.message' }), { target: { value: 'x' } });
|
|
126
|
+
expect(screen.getByRole('button', { name: 'Chat.send' }).disabled).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([[4401, 'Chat.sessionEnded'], [4403, 'Chat.accessLost'], [4429, 'Chat.rateLimited']])('stops reconnecting on close code %s and shows its state', async (code, text) => {
|
|
130
|
+
const socket = await openChat(makeApi());
|
|
131
|
+
act(() => socket.close(code));
|
|
132
|
+
expect(screen.getByText(text)).toBeTruthy();
|
|
133
|
+
expect(FakeSocket.instances).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('shows the not-configured state and loads no chat list', async () => {
|
|
137
|
+
const api = makeApi({ getStatus: vi.fn().mockResolvedValue({ status: 'not_configured' }) });
|
|
138
|
+
render(<ChatWorkspace api={api} />);
|
|
139
|
+
expect(await screen.findByText('Chat.unavailable')).toBeTruthy();
|
|
140
|
+
expect(screen.getByText('Chat.noModel')).toBeTruthy();
|
|
141
|
+
expect(screen.queryByRole('button', { name: 'Chat.newChat' })).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('opens an archived chat read-only: Send is disabled and Restore is offered', async () => {
|
|
145
|
+
const archivedChat = { ...baseChat, archived_at: '2026-10-01T00:00:00Z' };
|
|
146
|
+
const api = makeApi({ listChats: vi.fn().mockImplementation(async ({ archived } = {}) => ({ results: archived ? [archivedChat] : [], next: null })) });
|
|
147
|
+
render(<ChatWorkspace api={api} wsUrl={(id) => `ws://test/${id}`} />);
|
|
148
|
+
fireEvent.click(await screen.findByRole('button', { name: /Chat.archived/ }));
|
|
149
|
+
fireEvent.click(screen.getByText('Budget'));
|
|
150
|
+
expect(await screen.findByText('Chat.archivedReadOnly')).toBeTruthy();
|
|
151
|
+
expect(screen.getByRole('textbox', { name: 'Chat.message' }).disabled).toBe(true);
|
|
152
|
+
expect(screen.getByRole('button', { name: 'Chat.send' }).disabled).toBe(true);
|
|
153
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'Chat.restore' })[0]);
|
|
154
|
+
await waitFor(() => expect(api.updateChat).toHaveBeenCalledWith(7, { archived: false }));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('shows an empty new chat', async () => {
|
|
158
|
+
const api = makeApi();
|
|
159
|
+
render(<ChatWorkspace api={api} wsUrl={(id) => `ws://test/${id}`} />);
|
|
160
|
+
fireEvent.click((await screen.findAllByRole('button', { name: 'Chat.newChat' }))[0]);
|
|
161
|
+
expect(await screen.findByText('Chat.emptyTitle')).toBeTruthy();
|
|
162
|
+
expect(api.createChat).toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('ChatWorkspace load order', () => {
|
|
167
|
+
it('shows no Reconnecting chip on the first connect', async () => {
|
|
168
|
+
render(<ChatWorkspace api={makeApi()} wsUrl={(id) => `ws://test/${id}`} />);
|
|
169
|
+
fireEvent.click(await screen.findByText('Budget'));
|
|
170
|
+
await waitFor(() => expect(FakeSocket.instances.length).toBe(1));
|
|
171
|
+
expect(screen.queryByText('Chat.reconnecting')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not request catalogue or chat lists when no model is configured', async () => {
|
|
175
|
+
const api = makeApi({ getStatus: vi.fn().mockResolvedValue({ status: 'not_configured' }) });
|
|
176
|
+
render(<ChatWorkspace api={api} />);
|
|
177
|
+
await screen.findByText('Chat.unavailable');
|
|
178
|
+
expect(api.getCatalogue).not.toHaveBeenCalled();
|
|
179
|
+
expect(api.listChats).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('ChatWorkspace wiring', () => {
|
|
184
|
+
it('calls the update endpoint when the picker selection changes', async () => {
|
|
185
|
+
const api = makeApi();
|
|
186
|
+
await openChat(api);
|
|
187
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sonnet · Chat.effort.high' }));
|
|
188
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.effort.low' }));
|
|
189
|
+
await waitFor(() => expect(api.updateChat).toHaveBeenCalledWith(7, { selection: { entry: 'cli-sonnet', effort: 'low' } }));
|
|
190
|
+
});
|
|
191
|
+
it('wires rename, archive and delete of a chat to the endpoints', async () => {
|
|
192
|
+
const api = makeApi();
|
|
193
|
+
render(<ChatWorkspace api={api} wsUrl={(id) => `ws://test/${id}`} />);
|
|
194
|
+
await screen.findByText('Budget');
|
|
195
|
+
const menu = () => fireEvent.click(screen.getAllByRole('button', { name: 'Chat.actions' })[0]);
|
|
196
|
+
menu();
|
|
197
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.rename' }));
|
|
198
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Chat.renameLabel' }), { target: { value: 'Plan' } });
|
|
199
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.save' }));
|
|
200
|
+
await waitFor(() => expect(api.updateChat).toHaveBeenCalledWith(7, { title: 'Plan' }));
|
|
201
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
202
|
+
menu();
|
|
203
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.archive' }));
|
|
204
|
+
await waitFor(() => expect(api.updateChat).toHaveBeenCalledWith(7, { archived: true }));
|
|
205
|
+
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
|
|
206
|
+
menu();
|
|
207
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Chat.delete' }));
|
|
208
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.delete' }));
|
|
209
|
+
await waitFor(() => expect(api.deleteChat).toHaveBeenCalledWith(7));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('offers no Retry on an archived chat', async () => {
|
|
213
|
+
const archivedChat = { ...baseChat, archived_at: '2026-10-01T00:00:00Z' };
|
|
214
|
+
const api = makeApi({
|
|
215
|
+
listChats: vi.fn().mockImplementation(async ({ archived } = {}) => ({ results: archived ? [archivedChat] : [], next: null })),
|
|
216
|
+
listTurns: vi.fn().mockResolvedValue({ results: [{ id: 5, seq: 1, user_text: 'q', assistant_text: 'half', state: 'interrupted', sources: [] }], next: null }),
|
|
217
|
+
});
|
|
218
|
+
render(<ChatWorkspace api={api} wsUrl={(id) => `ws://test/${id}`} />);
|
|
219
|
+
fireEvent.click(await screen.findByRole('button', { name: /Chat.archived/ }));
|
|
220
|
+
fireEvent.click(screen.getByText('Budget'));
|
|
221
|
+
await screen.findByText('Chat.interrupted');
|
|
222
|
+
expect(screen.queryByRole('button', { name: 'Chat.retry' })).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('keeps an interrupted turn visible when Retry is clicked without a connection', async () => {
|
|
226
|
+
const api = makeApi({ listTurns: vi.fn().mockResolvedValue({ results: [{ id: 5, seq: 1, user_text: 'Again', assistant_text: 'half', state: 'interrupted', sources: [] }], next: null }) });
|
|
227
|
+
const socket = await openChat(api);
|
|
228
|
+
await screen.findByText('Chat.interrupted');
|
|
229
|
+
act(() => socket.close(4403));
|
|
230
|
+
fireEvent.click(screen.getByRole('button', { name: 'Chat.retry' }));
|
|
231
|
+
expect(screen.getByText('Again')).toBeTruthy();
|
|
232
|
+
expect(socket.sent).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -15,6 +15,7 @@ vi.mock('@mui/icons-material', () => ({
|
|
|
15
15
|
import {
|
|
16
16
|
authTranslations as publicAuthTranslations,
|
|
17
17
|
chartsTranslations as publicChartsTranslations,
|
|
18
|
+
chatTranslations as publicChatTranslations,
|
|
18
19
|
messagingTranslations as publicMessagingTranslations,
|
|
19
20
|
notificationsTranslations as publicNotificationsTranslations,
|
|
20
21
|
onboardingTranslations as publicOnboardingTranslations,
|
|
@@ -29,6 +30,7 @@ const bundleImporters = import.meta.glob('../src/i18n/*.ts');
|
|
|
29
30
|
const PUBLIC_BUNDLES = {
|
|
30
31
|
authTranslations: publicAuthTranslations,
|
|
31
32
|
chartsTranslations: publicChartsTranslations,
|
|
33
|
+
chatTranslations: publicChatTranslations,
|
|
32
34
|
messagingTranslations: publicMessagingTranslations,
|
|
33
35
|
notificationsTranslations: publicNotificationsTranslations,
|
|
34
36
|
onboardingTranslations: publicOnboardingTranslations,
|
package/tsconfig.build.json
CHANGED
|
@@ -43,3 +43,5 @@ Fresh native `/root/ucm_auth13_author`, requested codex/gpt-6-luna/high, complet
|
|
|
43
43
|
Six fresh CLI codex/gpt-6-luna read-only lanes ran concurrently against the same frozen diff, including package/changelog and complete component/caller context. Each exited 0 with requested model, openai provider and library cwd confirmed in banner. Envelope, duplication, UI and security reported No findings. Regression raised mutable fallback identity for consumers without IDs: not accepted as a defect in the supported AuthContext contract, which maps the immutable API id; without an immutable identity the component cannot establish that two changed fallback identities are the same person, and preserving consent across them would weaken account isolation. Tests raised that identity/logout/hydration guards also pass against the previous code: not accepted; these are explicitly required preservation cases and catch a naive freeze-after-first-hydration fix, while two same-person tests discriminate the actual defect and submitted consent values. Review: codex/gpt-6-luna; lenses envelope, regression, duplication, tests, ui, security; 2 raised, 0 accepted, worst accepted none. Some optional narrow reads failed process setup; reviewers completed from the full inline diff/context. UI attempted a second blocked narrow read; no command actually executed. No screenshots or image reviews.
|
|
44
44
|
|
|
45
45
|
npm version verified as 3.13.1 before reserving patch 3.13.2. Existing publish.yml builds and runs the library suite on main push before Trusted Publishing; no manual dispatch. Root inspected the consumer's intervening 3.12.0-to-3.13.1 code: AccountPage adds optional showProfileStatements default true, preserving existing controls/payload; SectionNav adds ListItem wrappers with existing selection/callbacks/labels. No changed exports or dependency requirements. Published patch and Spesix installed-package proof remain pending.
|
|
46
|
+
|
|
47
|
+
Release completion: main f29d1dd, publish workflow38640512864 succeeded (build,90 test files and npm Trusted Publishing). Public registry confirms3.13.2, published2026-10-14T21:33:03Z after propagation. Spesix consumed exact3.13.2 in a30de18; real installed Welcome/ProfileComponent, AuthRoutesIntegration and AccountHub tests8/8 passed, shared i18next resolution confirmed. Spesix's separate SPX-DEP-5 owns its pending real-browser/capture CI38641555651@7eaefcc. The library correction/release is complete; no production app promotion occurred.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# UCM-CHAT-1 — Shared AI chat surface: chat list, streaming thread, sources, model and effort picker
|
|
2
|
+
|
|
3
|
+
Prepared: 2026-10-15. Target repository: `ui-core-micha` (`main`). Backend counterpart and transport contract: `django-core-micha/DCM-CHAT-1`. First consumer: `cockpit/CP-CHAT-4`.
|
|
4
|
+
|
|
5
|
+
# A. Envelope — addressed to everyone
|
|
6
|
+
|
|
7
|
+
## Goal and expected outcome
|
|
8
|
+
|
|
9
|
+
`django-core-micha/DCM-CHAT-1` adds a shared AI chat core with a fixed WebSocket and REST contract. This order adds the matching surface to ui-core-micha, so that an app shows a complete chat by mounting one component. maana's chat UI (`maana/frontend/src/qda/assistant/`) is the functional template, without its proposals, grants and plan views.
|
|
10
|
+
|
|
11
|
+
Expected outcome: ui-core-micha exports a chat workspace that lists the user's chats (with archive), streams answers into a thread, renders Markdown safely, shows the sources each answer used, and lets the user pick model and effort per chat. The parts are also exported individually so that an app can arrange them differently.
|
|
12
|
+
|
|
13
|
+
## Visual spec
|
|
14
|
+
|
|
15
|
+
Prototype: [`work-orders/assets/UCM-CHAT-1-ai-chat.html`](assets/UCM-CHAT-1-ai-chat.html), drawn in cockpit's theme as the first consumer. It shows:
|
|
16
|
+
- at 1280 px, the main working state;
|
|
17
|
+
- at 375 px, the thread, the chat list drawer and the model picker sheet;
|
|
18
|
+
- a 1280 px gallery of the edge states.
|
|
19
|
+
|
|
20
|
+
The prototype declares its coverage at the top. The built components take every colour, type and radius from the consuming app's MUI theme, never from literals.
|
|
21
|
+
|
|
22
|
+
**Parity guardrail:** the prototype governs the look and the interaction only. Behaviour, permissions and the data contract come from `DCM-CHAT-1` and are not changed by anything drawn here.
|
|
23
|
+
|
|
24
|
+
## Operator decisions this Envelope records (2026-10-15)
|
|
25
|
+
|
|
26
|
+
- Several persistent chats per user, in a list. Each chat can be renamed, archived and deleted. Archived chats sit behind an "Archived" entry with a count, open read-only, and can be restored.
|
|
27
|
+
- Model and effort are chosen per chat, from the app's catalogue. The picker groups the entries by driver and marks pay-per-use entries. A change applies from the next turn, and the thread marks where the model changed.
|
|
28
|
+
- The sources of each answer appear as chips under it. A chip shows the source's short label, and the full reference appears on hover.
|
|
29
|
+
- A prototype was requested and is the visual spec.
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
- **Workspace component.** At wide widths it has a list column and a thread column. At narrow widths the list becomes a drawer and the picker a bottom sheet; the prototype shows the 375 px arrangement.
|
|
34
|
+
- **Chat list:**
|
|
35
|
+
- new chat;
|
|
36
|
+
- select a chat;
|
|
37
|
+
- per-row overflow menu with Rename, Archive and Delete;
|
|
38
|
+
- Delete asks for confirmation inside the UI;
|
|
39
|
+
- the archived section with its count;
|
|
40
|
+
- restore an archived chat.
|
|
41
|
+
- **Thread:**
|
|
42
|
+
- user messages;
|
|
43
|
+
- assistant answers rendered as Markdown, streamed live from `delta` frames;
|
|
44
|
+
- a typing indicator before the first delta;
|
|
45
|
+
- a tool-activity chip while a tool runs, labelled with the tool's own label from the `tool` frame;
|
|
46
|
+
- source chips once the turn completes;
|
|
47
|
+
- a quiet model and effort line under each answer;
|
|
48
|
+
- a divider where the model changed between turns;
|
|
49
|
+
- older turns loaded by cursor when scrolling up.
|
|
50
|
+
- **Composer:** a multi-line input and Send. Stop appears next to Send while a turn runs. Send stays disabled while a turn runs, while the chat is archived, and while the connection is down.
|
|
51
|
+
- **Model and effort picker:** a popover at wide widths and a bottom sheet at narrow widths.
|
|
52
|
+
- Entries come from the catalogue endpoint, grouped by driver, with the billing tag on pay-per-use entries.
|
|
53
|
+
- Effort is a segmented control with the four normalised levels. Levels the selected entry does not allow are disabled.
|
|
54
|
+
- The current selection shows in the thread bar as "model · effort".
|
|
55
|
+
- **Edge states, as drawn:**
|
|
56
|
+
- a new chat with no messages;
|
|
57
|
+
- the usage limit reached, with "Change model";
|
|
58
|
+
- an interrupted answer with an "Interrupted" chip and Retry;
|
|
59
|
+
- connection lost, with a "Reconnecting" chip;
|
|
60
|
+
- no model configured on this server;
|
|
61
|
+
- an archived chat opened.
|
|
62
|
+
- **Socket hook:** one WebSocket per open chat, reconnecting with backoff and no automatic resend. It parses the frames listed in `DCM-CHAT-1`, and on close codes 4401, 4403 and 4429 it stops reconnecting and surfaces the matching state.
|
|
63
|
+
- **API client:** a default implementation of every REST call in `DCM-CHAT-1`. The default set is exported, and a consumer can override single functions while the others keep their defaults.
|
|
64
|
+
- This avoids the `MessagingProvider` limitation described in `webapp-management/SHARED_CAPABILITIES.md`: there, passing any `api` prop replaces the whole default set.
|
|
65
|
+
- **Markdown rendering** uses `react-markdown` with `remark-gfm`, configured like maana's `MarkdownReply.jsx`: no raw HTML, no images, and links rendered as text. Both packages are new dependencies of this repository. They are approved with this order.
|
|
66
|
+
- **Strings:** all strings are registered through ui-core-micha's i18n contract in a new translations module, in German, English, French and Swahili like the existing modules.
|
|
67
|
+
- **Documentation:** a row in `webapp-management/SHARED_CAPABILITIES.md` for the chat surface, as a small companion change in that repository.
|
|
68
|
+
|
|
69
|
+
## Copy list (released with this Envelope)
|
|
70
|
+
|
|
71
|
+
Hint and state texts beyond control labels, verbatim from the prototype:
|
|
72
|
+
- "Usage limit reached for {model}. Choose another model or try again later."
|
|
73
|
+
- "The answer could not be completed."
|
|
74
|
+
- "Chat is not available"
|
|
75
|
+
- "No language model is configured on this server."
|
|
76
|
+
- "pay per use" (catalogue billing tag)
|
|
77
|
+
- Chips: "Interrupted", "Reconnecting", "Archived".
|
|
78
|
+
|
|
79
|
+
The tool-activity text, for example "Searching the knowledge base", is not ui-core-micha copy. It is the label each app gives its tool.
|
|
80
|
+
|
|
81
|
+
## Token delta list
|
|
82
|
+
|
|
83
|
+
None. Drawn from the theme.
|
|
84
|
+
|
|
85
|
+
## Replaces / removes
|
|
86
|
+
|
|
87
|
+
Nothing. This is a new surface. The existing `messaging` components stay unchanged.
|
|
88
|
+
|
|
89
|
+
## Non-goals
|
|
90
|
+
|
|
91
|
+
- Proposals, plans, grants, context inspection, or anything else from maana's domain UI.
|
|
92
|
+
- Credential entry for own logins or API keys (stage 4, `django-core-micha/DCM-CHAT-3`).
|
|
93
|
+
- Search across chats, sharing, attachments, voice input.
|
|
94
|
+
- Mounting the surface in any app. cockpit does that in `CP-CHAT-4`.
|
|
95
|
+
- Changes to `messaging/`, `notifications/` or the theme factory.
|
|
96
|
+
|
|
97
|
+
## Tier
|
|
98
|
+
|
|
99
|
+
Tier 3: a new shared-core surface with new dependencies.
|
|
100
|
+
|
|
101
|
+
## Risks
|
|
102
|
+
|
|
103
|
+
- **Markdown as an injection path:** answers can echo text from retrieved documents. The renderer must never interpret raw HTML or load images or links automatically. The allow-list configuration above is the control.
|
|
104
|
+
- **Contract drift:** the component is built against the transport contract of `DCM-CHAT-1` before that module is published. Any deviation found later is a change to that contract, not a local adaptation here.
|
|
105
|
+
- **Theme coupling:** literal colours copied from the prototype would make the component look like cockpit in every app. Colours must come from the theme.
|
|
106
|
+
|
|
107
|
+
## Required tests to write
|
|
108
|
+
|
|
109
|
+
- Socket hook:
|
|
110
|
+
- deltas append to the streaming answer;
|
|
111
|
+
- the `turn` frame replaces it with the final text and sources;
|
|
112
|
+
- reconnect without resending;
|
|
113
|
+
- 4401, 4403 and 4429 stop reconnecting and show their state.
|
|
114
|
+
- Thread:
|
|
115
|
+
- Markdown renders a list and a table;
|
|
116
|
+
- raw HTML in an answer is shown as text and not executed;
|
|
117
|
+
- an image reference is not loaded;
|
|
118
|
+
- source chips show the label, with the reference on hover;
|
|
119
|
+
- the model-change divider appears between turns with different selections.
|
|
120
|
+
- Picker:
|
|
121
|
+
- entries are grouped by driver;
|
|
122
|
+
- a pay-per-use entry shows its tag;
|
|
123
|
+
- effort levels the entry does not allow are disabled;
|
|
124
|
+
- a change calls the update-selection endpoint.
|
|
125
|
+
- Chat list:
|
|
126
|
+
- rename, archive, restore and delete call their endpoints;
|
|
127
|
+
- delete asks for confirmation first;
|
|
128
|
+
- an archived chat disables Send.
|
|
129
|
+
- Edge states: the usage-limit, interrupted and not-configured states render from their error codes.
|
|
130
|
+
- API client: overriding one function keeps the defaults for all others.
|
|
131
|
+
- Translations: every key of the new module exists in all four languages.
|
|
132
|
+
|
|
133
|
+
# B. Implementation map — addressed to the implementer
|
|
134
|
+
|
|
135
|
+
## Context package
|
|
136
|
+
|
|
137
|
+
Work from this package; do not explore broadly. Open only the files named here to verify. Target working directory: `C:/Users/Micha Bigler/Documents/webapps/ui-core-micha` (never the workspace root). Dependencies `react-markdown` 10.1.0 and `remark-gfm` 4.0.1 are already installed in `package.json` and the lockfile; do not touch either.
|
|
138
|
+
|
|
139
|
+
**Templates to read (maana, read-only, do not edit):** `maana/frontend/src/qda/assistant/` (sibling directory of this repo under the workspace root) — `useAssistantSocket.js` (reconnect with backoff, per-chat socket, close-code mapping), `MarkdownReply.jsx` (the allow-list renderer: copy its approach, but take the table/code region labels from this repo's i18n and colours only from the theme), `ChatThread.jsx`, `ChatList.jsx`, `ChatComposer.jsx`, `ChatWorkspace.jsx` (frame handling around line 118), `assistantApi.js`. Take the structure, not maana's proposals, grants, plans, context dialog or project id. Visual spec: `work-orders/assets/UCM-CHAT-1-ai-chat.html` — read it first; it governs look and interaction, with every colour, radius and type size taken from the MUI theme (palette tokens such as `primary.main`, `text.secondary`, `divider`, `action.hover`, `success/warning/error`), never a literal.
|
|
140
|
+
|
|
141
|
+
**Repo conventions to match (verify in place):**
|
|
142
|
+
- Source is `.jsx`/`.js` under `src/`, tests in `tests/*.test.jsx` (vitest + @testing-library/react + jsdom); look at `tests/Thread.test.jsx`, `tests/ConversationList.test.jsx` and `tests/MessagingProvider.test.jsx` for how a component is tested here (MUI icons are `vi.mock`ed per test file).
|
|
143
|
+
- HTTP goes through `src/auth/apiClient` exactly like `src/messaging/api.js` (default export `apiClient`, each function returns `response.data`).
|
|
144
|
+
- Strings: `useTranslation()` with keys in a new bundle `src/i18n/chatTranslations.ts` that exports exactly one const `chatTranslations`, entries `{ de, en, fr, sw }`, key prefix `Chat.` (check no key collides with another bundle). Register it in three places: `src/i18n/uiCoreTranslations.js` (spread), `src/index.js` (public export next to `messagingTranslations`), and `tests/i18nAggregate.test.js` (`PUBLIC_BUNDLES` plus its import list). German uses the formal variant.
|
|
145
|
+
- Public exports are added to `src/index.js` in a block next to the messaging block (line ~113). Do not edit `src/messaging/**`, `src/notifications/**` or `src/theme/**`.
|
|
146
|
+
|
|
147
|
+
## Wire shape (binding on this order; the Orchestrator pinned it from maana's implementation because DCM-CHAT-1 is not published yet)
|
|
148
|
+
|
|
149
|
+
The Envelope fixes WHICH frames and endpoints exist; the field names below are the working assumption and must be isolated in ONE module (`src/chat/wire.js`: URL builders, frame/field names, the three close codes) so a later contract change touches one file.
|
|
150
|
+
|
|
151
|
+
- **WebSocket** per chat: URL from a `wsUrl(chatId)` option, default `${ws|wss}://${location.host}/ws/assistant/chats/${chatId}/`. Client frames `{type:"send", message}` and `{type:"stop"}`. Server frames: `{type:"history", turns:[turn…]}` on connect; `{type:"delta", text}`; `{type:"tool", phase:"start"|"end", name, label, sources:[{label, reference}]}`; `{type:"turn", turn}`; `{type:"notice", code, text?}`; `{type:"error", code}` with `code` one of `quota_exceeded`, `provider_error`, `timeout`, `prompt_too_long`, `invalid_output`, `security_violation`, `not_configured`, `rate_limited`, `invalid_selection`, `archived`, `turn_active`, `invalid_frame`. Close codes 4401 -> `session_ended`, 4403 -> `access_lost`, 4429 -> `rate_limited`: no reconnect, state surfaced. Any other close -> reconnect with backoff (1 s, 2 s, 4 s, then stop and show the "connection lost" state with a manual Retry), **never resend a message automatically**; ignore malformed JSON frames.
|
|
152
|
+
- **REST**, base path from an `apiBase` option (default `/api/assistant/`): `GET catalogue/` -> `{entries:[{id, driver, model, label, efforts:["low"|"medium"|"high"|"max"…], billing:"subscription"|"pay_per_use"}], default:{entry, effort}}`; `GET chats/?archived=<bool>&cursor=` -> `{results:[chat…], next}`; `POST chats/` body `{scope?}` -> chat; `PATCH chats/{id}/` body any of `{title, archived, selection:{entry, effort}}` -> chat; `DELETE chats/{id}/`; `GET chats/{id}/turns/?cursor=` -> `{results:[turn…], next}` (newest page first, `next` = cursor for older); `GET status/` -> `{status:"available"|"not_configured"}`.
|
|
153
|
+
- **chat** = `{id, title, archived_at|null, selection:{entry, effort}, updated_at}`. **turn** = `{id, seq, user_text, assistant_text, state:"generating"|"completed"|"failed"|"cancelled"|"interrupted", sources:[{label, reference}], driver, model, effort, error_code|null}`.
|
|
154
|
+
|
|
155
|
+
## Named files to create (all new; owns `src/chat/**`, `src/i18n/chatTranslations.ts`, `tests/chat*.test.*`, and the three registration edits above)
|
|
156
|
+
|
|
157
|
+
- `src/chat/wire.js` — constants and URL builders above.
|
|
158
|
+
- `src/chat/api.js` — `createChatApi(overrides = {}, { apiBase })` and the exported `defaultChatApi`: one function per REST call (`getCatalogue, listChats, createChat, updateChat, deleteChat, listTurns, getStatus`) returning `response.data`; `{...defaults, ...overrides}` so overriding one function keeps every other default (the point of this design; contrast `MessagingProvider`).
|
|
159
|
+
- `src/chat/useChatSocket.js` — the hook: `(chatId, {wsUrl, onFrame, onOpen, onClose})` -> `{status, connected, send, reconnect}`. State machine as above. Pass the chat id with every delivered frame so a late frame of a left chat is never applied (maana does this).
|
|
160
|
+
- `src/chat/MarkdownReply.jsx` — allow-list renderer. Allowed elements as maana, `unwrapDisallowed`, links render as text (label plus address when different), images never fetched (alt text or address as text), raw HTML shown as text (no rehype-raw), tables and code in focusable scroll regions, a very long reply falls back to plain text (constant `MARKDOWN_LIMIT`). Styling through `sx` with theme tokens only.
|
|
161
|
+
- `src/chat/ChatThread.jsx` — turns, streaming answer, typing indicator before first delta, tool-activity chip (label from the `tool` frame), source chips (short `label`, full `reference` as tooltip), the quiet "model · effort" line under each answer, a divider between turns whose driver/model/effort differ, older turns loaded by cursor on scroll up/"load older", state chips ("Interrupted" + Retry, "Reconnecting"), and the error states.
|
|
162
|
+
- `src/chat/ChatList.jsx` — new chat, select, per-row overflow menu (Rename, Archive, Delete), Delete confirmation inside a MUI Dialog, "Archived" entry with count, restore of an archived chat, load more.
|
|
163
|
+
- `src/chat/ChatComposer.jsx` — multi-line input, Send, Stop while a turn runs; Send disabled while a turn runs, while the chat is archived, and while the connection is down. Enter sends, Shift+Enter newline.
|
|
164
|
+
- `src/chat/ModelPicker.jsx` — popover (wide) / bottom `Drawer` sheet (narrow, via `useMediaQuery(theme.breakpoints.down('md'))`), entries grouped by driver, "pay per use" tag, effort `ToggleButtonGroup` with levels the entry does not allow disabled; a change calls `updateChat(id, {selection})`; the thread bar shows "model · effort".
|
|
165
|
+
- `src/chat/ChatWorkspace.jsx` — composes list + thread + composer + picker. Props: `apiBase`, `wsUrl`, `api` (partial overrides, merged), `scope`, `sx`. Wide: two columns. Narrow: list in a `Drawer`. Loads status/catalogue, owns the active chat, the streaming state and the edge states: new chat empty state, usage limit (`quota_exceeded` -> "Usage limit reached for {model}. Choose another model or try again later." + "Change model"), interrupted/failed ("The answer could not be completed." + Retry, which re-sends that turn's `user_text` as a new `send` only on the user's click), connection lost ("Reconnecting" chip), `not_configured` ("Chat is not available" / "No language model is configured on this server."), archived (read-only, "Archived" chip, Restore).
|
|
166
|
+
- No `src/chat/index.js`; export from `src/index.js`: `ChatWorkspace, ChatList, ChatThread, ChatComposer, ModelPicker, MarkdownReply, useChatSocket, createChatApi, defaultChatApi, chatTranslations`.
|
|
167
|
+
- `src/i18n/chatTranslations.ts` — every string of the Envelope's copy list verbatim in English, plus control labels, aria-labels and the Markdown region labels; all four languages.
|
|
168
|
+
- Tests (names are guidance): `tests/chatSocket.test.jsx`, `tests/chatThread.test.jsx`, `tests/chatPicker.test.jsx`, `tests/chatList.test.jsx`, `tests/chatEdgeStates.test.jsx`, `tests/chatApi.test.js`, `tests/chatTranslations.test.js`, plus a one-line export check in the style of `tests/messagingExports.test.js`. The socket tests use a fake `WebSocket` class and fake timers.
|
|
169
|
+
|
|
170
|
+
## Invariants, pitfalls, do-not-touch
|
|
171
|
+
|
|
172
|
+
- No raw HTML, no image fetch, no auto-loading links anywhere in answers; no `dangerouslySetInnerHTML`. User message text is rendered as plain text.
|
|
173
|
+
- No literal colours, radii or font sizes copied from the prototype; theme tokens and spacing units only. Light and dark themes must both work.
|
|
174
|
+
- Accessibility: the streaming answer sits in a polite live region that is not re-announced on every delta (`aria-busy` while generating), icon buttons have aria-labels, the picker and menus are keyboard operable, touch targets at least 40 px at narrow width.
|
|
175
|
+
- Do not edit `src/messaging/**`, `src/notifications/**`, `src/theme/**`, `package.json`, the lockfile, `README.md`, `CHANGELOG.md` or the register. Docs and the version bump are the Orchestrator's.
|
|
176
|
+
- Package build is `tsc -p tsconfig.build.json` (allowJs, `src` only): new files must compile; `tests/packageTreeShaking.test.js` and `tests/checkKitIntegration.test.jsx` exist: do not introduce a top-level side effect in the new modules.
|
|
177
|
+
- The working tree contains unrelated foreign changes (`WORK_ORDERS.md`, `work-orders/UCM-DEP-1.md`, `package.json`, `pnpm-lock.yaml`): leave them alone.
|
|
178
|
+
|
|
179
|
+
## Preamble
|
|
180
|
+
|
|
181
|
+
> The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source and test files and for running the tests you wrote — nothing else.** Do NOT install dependencies, touch a lockfile, run a package manager, or tidy up stray files; if something in the repo state blocks you, stop and report it as `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT `git add`/`commit`/`push` — leave every change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review). The orchestrator re-runs the authoritative set + does the independent review after you finish — those are the gate; your own run does not count as the gate.
|
|
182
|
+
>
|
|
183
|
+
> Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# C. Orchestrator only — not addressed to the implementer
|
|
187
|
+
|
|
188
|
+
> **If this WO was dispatched to you as an implementer: STOP here.**
|
|
189
|
+
|
|
190
|
+
Gate #1 open until recorded in the register row. Can start against the `DCM-CHAT-1` contract in parallel; publish as a new minor version. Prototype in scope: ucm has no host app of its own, so the rendered side-by-side at 375 px and 1280 px runs where the surface is first mounted, in `cockpit/CP-CHAT-4`, and is that order's commit gate; a deviation it finds in the shared components is fixed here and republished.
|