@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,35 @@
1
+ name: CI
2
+
3
+ on: workflow_dispatch
4
+
5
+ permissions:
6
+ contents: read
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Check out code
14
+ uses: actions/checkout@v7
15
+ with:
16
+ fetch-depth: 2
17
+
18
+ - name: Set up Node
19
+ uses: actions/setup-node@v7
20
+ with:
21
+ node-version: '24.21.0'
22
+
23
+ - name: Set up pnpm
24
+ uses: pnpm/action-setup@v6
25
+ with:
26
+ version: 12.10.1
27
+
28
+ - name: Install dependencies (pnpm)
29
+ run: pnpm install --frozen-lockfile
30
+
31
+ - name: Build
32
+ run: pnpm run build
33
+
34
+ - name: Run tests
35
+ run: pnpm run test
@@ -20,14 +20,14 @@ jobs:
20
20
 
21
21
  steps:
22
22
  - name: Check out code
23
- uses: actions/checkout@v6
23
+ uses: actions/checkout@v7
24
24
  with:
25
25
  fetch-depth: 2
26
26
 
27
27
  - name: Set up Node
28
- uses: actions/setup-node@v6
28
+ uses: actions/setup-node@v7
29
29
  with:
30
- node-version: '24.15.0'
30
+ node-version: '24.21.0'
31
31
  registry-url: 'https://registry.npmjs.org'
32
32
 
33
33
  - name: Decide whether to publish
@@ -99,7 +99,7 @@ jobs:
99
99
  if: steps.version_check.outputs.should_publish == 'true'
100
100
  uses: pnpm/action-setup@v6
101
101
  with:
102
- version: 11.1.2
102
+ version: 12.10.1
103
103
 
104
104
  - name: Show version from package.json
105
105
  if: steps.version_check.outputs.should_publish == 'true'
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.14.4 - UCM-DEP-1
4
+
5
+ Dependency updates. The runtime dependency `@fontsource/dm-sans` moves to ^5.3.0; the peer dependency ranges are
6
+ unchanged. Tooling: TypeScript 7 (`moduleResolution: Bundler` in `tsconfig.build.json`), vitest 5, jsdom 30, pnpm 12
7
+ and updated GitHub Actions in both workflows. No source or API change.
8
+
9
+ ## 3.14.3 - UCM-CHAT-1 publish fix
10
+
11
+ 3.14.0 to 3.14.2 were never published: `themeCompleteness` rejected the chat components' inline `sx` on baseline-styled
12
+ MUI components. The chat surface now follows the kit convention (exported top-level `sx` constants registered in
13
+ `KIT_COMPONENT_SX_REGISTRY`; no `sx` that repeats a theme value such as the button minimum height). No behaviour
14
+ change; this release contains everything from 3.14.0 to 3.14.2.
15
+
16
+ ## 3.14.2 - UCM-CHAT-2
17
+
18
+ The chat model picker shows readable, translated names for the drivers `claude_cli`, `anthropic_api` and
19
+ `openai_api` as group heading and accessible name (`Chat.driver.<id>`, de/en/fr/sw). An unknown driver id still
20
+ appears as its raw id. No other chat text or behaviour changes.
21
+
22
+ ## 3.14.1 - UCM-CHAT-1 post-landing review
23
+
24
+ `ChatThread` starts at most one older-turns request at a time, also for rapid scroll events. `ModelPicker` shows no
25
+ trailing separator when a chat has no effort set.
26
+
27
+ ## 3.14.0 - UCM-CHAT-1
28
+
29
+ New shared AI chat surface. `ChatWorkspace` shows the user's chats (rename, archive, restore, delete), streams
30
+ answers into a thread, renders them as Markdown (no raw HTML, no images, links as text), shows the sources of each
31
+ answer as chips and lets the user pick model and effort per chat. The parts are exported on their own:
32
+ `ChatList`, `ChatThread`, `ChatComposer`, `ModelPicker`, `MarkdownReply`, `useChatSocket`.
33
+ `createChatApi(overrides, { apiBase })` returns the full default REST client; overriding one function keeps the
34
+ others. Strings are in `chatTranslations` (de, en, fr, sw), included in `uiCoreTranslations`. Adds the dependencies
35
+ `react-markdown` and `remark-gfm`. The wire shape (URLs, frame and field names) is isolated in `src/chat/wire.js`
36
+ and follows `django-core-micha/DCM-CHAT-1`.
37
+
3
38
  ## 3.13.2 - UCM-AUTH-13
4
39
 
5
40
  `ProfileComponent` preserves edited names and consent values when the same person's
package/WORK_ORDERS.md CHANGED
@@ -14,12 +14,14 @@ rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
14
14
  | `CHART-*` | Shared chart / data-viz kit (`ChartFrame` + MUI X-Charts presets + neutral palette/formatters) |
15
15
  | `THEME-*` | Shared MUI theme baseline (`createAppTheme` factory, token surfaces, completeness assertion) |
16
16
  | `MSG-*` | Shared messaging platform, Phase B (dcm register is canonical; this repo's rows are its ucm-side WOs) |
17
+ | `CHAT-*` | Shared AI chat surface (chat list, streaming thread, sources, model picker). The django-core-micha register is canonical for the core (`DCM-CHAT-*`); this repo's rows are its ucm-side WOs |
17
18
  | `DX-*` | Developer experience / repo tooling (ucm-local prefix; unrelated to `django-core-micha`'s own `DX-*`) |
18
19
  | `AUTH-*` | Auth components UX (login/password/MFA/profile forms, auth error i18n, and the account screen's own surfaces) |
19
20
  | `I18N-*` | The shared kit's i18n **contract** — how a consumer registers ucm's strings, and what happens when it forgets. Distinct from `AUTH-*`, which owns the auth strings themselves |
20
21
  | `PRIM-*` | Shared dashboard/data primitives — small presentational components that are neither chart (`CHART-*`) nor app chrome (`SHELL-*`) |
21
22
  | `SHELL-*` | Shared app chrome and navigation shells (`UserMenu`, mobile bottom nav, secondary-navigation shell) — used since `SHELL-1`, documented here 2026-08-11 |
22
23
  | `TEST-*` | Consumer integration checks the kit ships for apps to run in their own suites |
24
+ | `CI-*` | This repository's own CI workflows (dispatch-only verification runs; `publish.yml` stays the publish path) |
23
25
 
24
26
  Introduce a new prefix when none fits and add it here. New WOs always get a
25
27
  prefixed ID; never reuse a bare flat number across workstreams.
@@ -28,11 +30,15 @@ prefixed ID; never reuse a bare flat number across workstreams.
28
30
 
29
31
  | ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
30
32
  |---|---|---|---|---|---|---|
31
- | UCM-AUTH-13 | [Preserve profile edits during refresh](work-orders/UCM-AUTH-13.md) | Keep unsaved names and consent for the same person; reset at account boundaries. Patch release and Spesix consumption approved. | 2026-10-14 | in-review | ab85c60 | Tier 3. Gate #1 approved by the operator in the Spesix orchestration chat on 2026-10-14. Fresh native configured codex/gpt-6-luna/high author `/root/ucm_auth13_author`; workspace-root cwd bootstrap, absolute target paths; effective provider/cwd/sandbox not exposed. Foreign scratchpad excluded. review: codex/gpt-6-luna · lenses: envelope,regression,duplication,tests,ui,security · 2 raised · 0 accepted · worst accepted: none. Parent 11 focused tests and TypeScript build passed. Patch 3.13.2 release and Spesix consumption pending; detailed dispositions in WO C. |
33
+ | UCM-CHAT-2 | [Readable driver names in the chat model picker](work-orders/UCM-CHAT-2.md) | Picker group headings (and any other driver display) show translated names for `claude_cli`, `anthropic_api`, `openai_api` instead of the raw ids; unknown ids fall back to the raw id. Correction to UCM-CHAT-1, found at acceptance against its prototype. | 2026-10-15 | done | f35b6ea | Tier 3 · Codex gpt-6-luna dispatched once, RESULT: DONE; Orchestrator reviewed and added the real-i18n test. review: codex/gpt-6-luna · lenses: envelope, regression, duplication, tests, ui · 3 raised · 2 accepted · worst accepted: defect (accepted: picker tests only saw mocked keys, effort-via-t() unpinned; both fixed with tests/chatDriverNames.test.jsx on the real translations; rejected: 'divider shows raw driver id' raised by two lenses, false: the divider shows model and effort only, no driver). Batch also covered the 3.14.1 delta da068fb and a tests lens given the actual test files (operator request 2026-10-15). Scoped tests 63 pass, tsc clean. Published as 3.14.2 by push to main; cockpit/CP-CHAT-4 pins it. Gate #1 recorded at ui-core-micha@8a9737b. |
34
+ | UCM-CHAT-1 | [Shared AI chat surface: chat list, streaming thread, sources, model and effort picker](work-orders/UCM-CHAT-1.md) | Workspace plus individually exported parts against the DCM-CHAT-1 contract; Markdown via `react-markdown` + `remark-gfm` (allow-listed: no raw HTML, no images); per-chat model and effort picker; source chips; edge states. Prototype [work-orders/assets/UCM-CHAT-1-ai-chat.html](work-orders/assets/UCM-CHAT-1-ai-chat.html). | 2026-10-15 | done | 2dede28 | Tier 3 · Prototyp. Codex gpt-6-luna dispatched once, returned BLOCKED with a skeleton; Orchestrator (Claude) completed it, so author = Claude. review: codex/gpt-6-luna · lenses: envelope, regression, duplication, tests, ui · 18 raised · 13 accepted · worst accepted: defect. Last fix batch (reconnect chip, status-first load, scroll pinning, effort i18n) re-checked by scoped tests only, not re-reviewed. Scoped chat tests 64 pass, tsc build clean; packageTreeShaking fails locally on Windows symlink EPERM, runs in publish.yml (tests before publish). Wire shape (URLs, frame/field names) pinned from maana in src/chat/wire.js because DCM-CHAT-1 is unpublished. Rendered 375/1280 side-by-side runs in cockpit/CP-CHAT-4. Docs row: webapp-management 955c2e8. Published as 3.14.0 by push to main. Gate #1 approved by the operator 2026-10-15 (click in session local_03d1141f-9577-4c9e-8ccf-3343b3b34a19). post-landing re-review of fix batch (operator request 2026-10-15): codex/gpt-6-luna · lenses: envelope, regression, duplication, tests, ui + sec (Markdown path, socket/url) · 4 raised · 2 accepted · worst accepted: defect; accepted: overlapping older-turn loads, trailing separator in picker label (fixed in da068fb, 3.14.1, +4 tests); rejected: wsUrl/apiBase scheme checks (developer-supplied host config, not user input) x2; tests lens could not ground a verdict (no test files in its material); the 3.14.1 delta itself is covered by scoped tests (68 pass) only. Markdown path: no raw HTML, image fetch or active link reachable. 3.14.1 delta da068fb and the tests lens were covered in the UCM-CHAT-2 batch (tests lens given the test files of 2dede28, 3.14.1 and UCM-CHAT-2): 0 defects against the 3.14.1 delta (single-flight guard and picker label are pinned by assertions); 1 raised, 1 accepted (effort-via-t() was not pinned; fixed with tests/chatDriverNames.test.jsx), worst accepted: defect. |
35
+ | UCM-AUTH-13 | [Preserve profile edits during refresh](work-orders/UCM-AUTH-13.md) | Keep unsaved names and consent for the same person; reset at account boundaries. Patch release and Spesix consumption approved. | 2026-10-14 | done | f29d1dd | Tier 3. Gate #1 approved by the operator in the Spesix orchestration chat on 2026-10-14. Fresh native configured codex/gpt-6-luna/high author `/root/ucm_auth13_author`; workspace-root cwd bootstrap, absolute target paths; effective provider/cwd/sandbox not exposed. Foreign scratchpad excluded. review: codex/gpt-6-luna · lenses: envelope,regression,duplication,tests,ui,security · 2 raised · 0 accepted · worst accepted: none. Parent 11 focused tests and TypeScript build passed. Released3.13.2: publish CI38640512864 build/90 test files/publish passed, registry confirmed. Spesix a30de18 installed-package consumer8 tests passed; separate SPX-DEP-5 owns real browser CI38641555651. Detailed dispositions in WO C. |
32
36
  | UCM-AUTH-12 | [Optional account profile statement text](work-orders/UCM-AUTH-12.md) | AccountPage showProfileStatements defaults true; false suppresses statement panels/requests while preserving both consent controls and their payload. Minor release 3.13.0, consumed by innoservice INNO-FIX-13. | 2026-10-13 | done | 97b7d83 | Tier 3 (shared-core account surface). Gate #1 explicitly approved in the innoservice Codex chat on 2026-10-13, including release and app pin bump. Separate configured Luna implementation and independent reviews; existing default and consent behavior are invariants. review: codex/gpt-6-luna · lenses: envelope, regression, duplication, tests, ui, security · 0 raised · 0 accepted · worst accepted: none. Parent scoped tests: AccountPage.profileStatements.test.jsx + AccountPage.test.jsx, 6 passed, correct kit cwd, maxWorkers2. Native author attempt EPERM before collection; parent gate green. Reviewed additive API/default and package release metadata; main push triggers the existing release build/suite/publish. Released 3.13.0: CI38422682968 build, release suite and npm Trusted Publishing all succeeded. App consumption/capture remains tracked under INNO-FIX-13. |
33
37
  | UCM-SHELL-8 | SectionNav lists contain direct button children | Grouped and overview entries need genuine list items containing the existing navigation buttons, preserving selection, drawer behavior and presentation. | 2026-10-13 | done | `d399f51` | Tier 3. Gate #1 approved by the operator on 2026-10-13: structural correction, tests/review, patch release and innoservice pin bump. Full scope and context: [work-orders/UCM-SHELL-8.md](work-orders/UCM-SHELL-8.md). Original CI38387014900/source9937adb evidence remains in innoservice/work-orders/assets/INNO-FIX-11-axe-nodes.json; CI38425506007/sourceebc35e1 reproduces the three account list violations on 3.13.0, recorded in INNO-FIX-11-candidate-b-axe-nodes.json. Initial native author /root/kit_sectionnav requested codex/gpt-6-luna; effective native metadata unexposed. Source and two focused test cases delivered; author run EPERM before collection, zero tests. Parent tests and independent review pending. review: codex/gpt-6-luna · lenses: envelope, regression, duplication, tests + UI · 1 raised · 0 accepted · worst accepted: none. Disposition in WO Part C: fabricated class-stub identity demand rejected against the real revert failure and preserved existing behavior checks. Parent 21 scoped tests passed (maxWorkers=2), TypeScript build exit 0. Core source accepted; publication, app pin and fresh consumer capture pending. Closing proof:3.13.1 published CI38435441867 (744tests89files/build/npm success); app exact pin/sourcec527918, CI38444366349 account375/1280 both axe0,console/network0,rubric22yes and parent actual-image inspection; all three list violations gone. No source change since reviewed d399f51. |
34
38
  | UCM-THEME-17 | Decide a less compressed baseline type scale | Design round (design-language process): inventory, instrument with 2-3 candidate scales next to today's baseline and MUI on real screens (hram, a survey site, jg-ferien, kerzenziehen), value rounds, frozen sheet. Measured 2026-10-04: page/card/section titles differ by 1-3 px, hierarchy rests on weight alone. Full order [`work-orders/UCM-THEME-17.md`](work-orders/UCM-THEME-17.md) | 2026-10-04 | done | `68dfc6a`, `998e36c`, `cb9dd89`, `c452224` | Tier 2 (document only) · operator decision 2026-10-04: baseline finding, not per-app overrides · implementation order follows the freeze · survey_app SVA-THEME-1 typography revert held until then · **frozen 2026-10-04** after two value rounds: candidate B, mobile h4 23, h1-h3 36/32/28; value source [`docs/TYPE-SCALE.md`](docs/TYPE-SCALE.md) · review: none, document order, the operator freeze is the gate · implementation UCM-THEME-18 |
35
39
  | UCM-THEME-18 | Ship the frozen baseline type scale | `tokens.js` typography per `docs/TYPE-SCALE.md`: h1-h3 36/32/28, h4 24 (23 below `sm`), h5 20, subtitle1 13; light fixture typography entries; minor release 3.12.0. Full order [`work-orders/UCM-THEME-18.md`](work-orders/UCM-THEME-18.md) | 2026-10-04 | 5be83d5 | | Tier 3 (shared-core) · reviewer + ui_reviewer · downstream per-app orders (h5 page titles -> h4, per-level weight overrides) written after the release - 2026-10-04: Codex implemented (gpt-6-luna; probe of stale 2026-10-03 record succeeded, available) - orchestrator fixed review findings - review: codex/gpt-6-luna - lenses: envelope,regression,duplication,tests + ui_review - 3 raised - 2 accepted - worst accepted: defect (h4 mobile key now follows an app-overridden sm, test added; completeness test strengthened; rejected: breakpointDownQuery vs theme.breakpoints.down, no theme exists at token time) - light fixture diff limited to typography entries - no rendered side-by-side (no prototype; values asserted from the resolved theme in light and dark) - release 3.12.0 |
40
+ | UCM-DEP-1 | All open Renovate updates in one change on main, one CI run | PRs #17 to #24 (npm non-major, actions, jsdom 30, pnpm 12, TypeScript 7, vitest 5) in one change; release only if runtime dependencies changed. Full order [work-orders/UCM-DEP-1.md](work-orders/UCM-DEP-1.md) | 2026-10-15 | planned | | Tier 3 (shared core, dependencies). Part of the PR close-out round of 2026-10-15 (operator decision 2026-10-15). Envelope by the Infra Expertenchat. Gate #1 approved by the operator 2026-10-15 (click in session local_1e5f212a-77db-4423-a507-983adf826bc4). |
41
+ | UCM-CI-1 | A dispatch-only CI workflow for ui-core-micha | `.github/workflows/ci.yml`: install, build, test on Linux, `workflow_dispatch` only, no publish, no secrets, so a candidate ref can be proven without releasing. Full order [work-orders/UCM-CI-1.md](work-orders/UCM-CI-1.md) | 2026-10-15 | done | 69b8920 | Tier 3 (CI). Requested by the operator in the UCM-CHAT Orchestrator session 2026-10-15 as the verification path for UCM-DEP-1; that instruction is the Gate #1 approval. Codex gpt-6-luna dispatched once, RESULT: DONE; file matches the Envelope. review: codex/gpt-6-luna · lenses: envelope+security (combined, one run; regression, duplication and tests not applicable to a 30-line dispatch-only file) · 0 raised · 0 accepted · worst accepted: none. Evidence: dispatched run 38739752689 on ci-test/UCM-CI-1 green (install, build, full test suite incl. packageTreeShaking on Linux); the ref was deleted. |
36
42
  | UCM-I18N-5 | [User-list pagination range text is hard-coded English](work-orders/UCM-I18N-5.md) | `UserListComponent`'s `TablePagination` sets `labelRowsPerPage` but no `labelDisplayedRows`, so German shows "1–4 of 4". | 2026-10-01 | done | 6b9d557 | Found in jg-ferien's rendered check 2026-10-01. Follows `UCM-I18N-4`. Tier 3. Done = released AND jg-ferien pin bumped. Envelope written 2026-10-01; operator: hand to the jg Paket-4 session. Implementation map filled 2026-10-02 (`a732025`): the translation bundle carries FOUR locales (de/fr/en/sw), not three — confirmed by reading `authTranslations.ts` directly; proposed two keys (exact-count + "more than" form) with a best-effort, not native-verified, Swahili wording. Implemented by codex/gpt-5.6-luna exactly per the map: `labelDisplayedRows` resolves `UserList.DISPLAYED_ROWS`/`UserList.DISPLAYED_ROWS_MORE_THAN` through `t()`, both keys added in all four locales. review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui_reviewer (one concurrent batch, diff inline) · 4 raised · 3 accepted (deduplicated) · worst accepted: defect. Accepted: the rendered English test would still pass against a reverted implementation, since the EN wording was deliberately made identical to MUI's own hardcoded default — added a second test swapping in a distinctive marker string for just that key, proving the render genuinely resolves through i18next, not MUI's fallback; `authTranslations.test.js`'s new key-parity check only asserted non-empty strings, not that each locale actually carries the `{{from}}`/`{{to}}`/`{{count}}` placeholders the others do — strengthened with an explicit per-locale placeholder check; self-identified (corroborated by the `tests` lens's own closing remark) that the `count === -1` ("more than") branch can never be reached through a render of `UserListComponent` itself (its own `count` prop is always a real array length) — extracted the inline callback into an exported, directly unit-tested `formatDisplayedRowsLabel(t)` rather than leaving it permanently untested dead code. Rejected: `regression`'s claim that hard-coding `labelDisplayedRows` "overrides any consuming app's MUI theme customisation" — speculative, no actual consumer override exists (same explicit-prop design as `labelRowsPerPage` since `UCM-I18N-4`), and the reviewer's own repo search found zero consumer snapshots/assertions relying on the old English text. `envelope`'s "release + pin bump missing from the diff" was correct but not a code defect — addressed by the remaining steps below, not a fix to the reviewed diff. `duplication`: 0 findings. `ui_reviewer`: 0 blocking findings; confirmed placeholder consistency across all 8 strings; Swahili wording flagged as advisory-only ("structurally plausible to a non-native reviewer, native-speaker verification advisable" — not re-asked of a native speaker here, recorded as open). Tests: 37/37 green (`userScreenTranslations.test.jsx`, `authTranslations.test.js`, `UserListComponent.test.jsx`). Released as `3.11.1` (patch — continues `UCM-I18N-4`'s i18n-gap category, not a new capability area), commit `6b9d557`; npm Trusted Publishing confirmed via the Actions job log (not a registry re-query — provenance publishes can lag), tarball `@micha.bigler/ui-core-micha@3.11.1`. jg-ferien's pin bumped 3.7.0 -> 3.11.1 same day, closed as jg's own `JG-DEPS-3` (`2c1d6e3`) — see that repo's `WORK_ORDERS.md` for the empirical safety checks (status-colour palette diff, `UserListComponent`-unused confirmation, full-suite run) this multi-version jump required. |
37
43
  | UCM-TEST-1 | A kit integration check every app runs in its own suite | Exported `checkKitIntegration({ i18n, wrapper, pages })` -> `{ findings }` (shape of `assertThemeComplete`): kit keys present in the app's i18n, one `i18next` instance, kit pages render in the app's providers without raw keys, the `invalid`-email case shows the field text. Replaces per-app hand-written wiring tests and manual auth walks. Full order [`work-orders/UCM-TEST-1.md`](work-orders/UCM-TEST-1.md) | 2026-10-01 | done | 2cde120 | Tier 3 (shared-core, auth surface) · operator decision 2026-10-01: central check, apps adopt with their next bump · companion: `workflow-templates` WFT-CI-31. Codex implemented (available today, no fallback). Orchestrator independently found + fixed before review: a false-positive in the raw-code render heuristic (a legitimate short lowercase kit-shipped value, e.g. German `"oder"` for `Auth.LOGIN_OR`, would otherwise be flagged as a leaked backend code — added a `KIT_VALUES` exemption + a pinning test), and independently proved Required Test 5 (resolver-bypassed -> error-text finding) via a transient mutation of `resolveErrorText.js`, restored clean. Independent `reviewer` (all 4 lenses) + `ui_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui · 9 raised · 6 accepted · worst accepted: blocker**. Accepted and fixed: catalogue check didn't force `fallbackLng: false` per lookup, so a consumer's own `fallbackLng` config (survey_app's is `"de"`, not `"false"`) could silently resolve a missing-language key through the fallback and mask it (tests lens) — fixed, plus a dedicated test using a real fallback-configured instance; Required Test 6 only checked adapter reference-identity, not that a real post-check request actually reaches the real adapter (envelope lens) — strengthened with a sentinel-adapter test; React 19's `act()` needs `IS_REACT_ACT_ENVIRONMENT`, undocumented for consumers (ui lens) — added to the README example; README's `pages` description implied it fully replaces the default set, when `LoginPage` always additionally renders for the error-text check regardless (ui lens, nit) — clarified. Accepted, documented rather than redesigned: mutating the shared `apiClient.defaults.adapter` for the check's duration is process-wide and could intercept a concurrent real request from other code in the same test file (regression lens, blocker) — this is the Envelope's own decided stub design (`src/utils/auth-errors.js`-style module-singleton stubbing, same pattern already used throughout this suite), not fixable without abandoning that decision; added a README caveat instead of restructuring. Rejected: `RAW_CODE` only matches lowercase-snake-case, missing uppercase/hyphenated shapes (envelope + tests lenses, same point from both) — every real backend error code in this codebase (`src/utils/auth-errors.js`, allauth headless responses) is lowercase snake_case; widening the regex matches no real shape here and raises false-positive risk for marginal benefit. `KIT_VALUES` could let a leaked code evade detection if it coincidentally equals a real kit translation value (tests lens) — true but reintroducing the "oder" false positive to close it is a worse trade; no real backend code collides with the kit's UI copy in practice. The correctly-wired/legitimate-word/resolver/adapter-restore tests would all pass against a hardcoded `{findings:[]}` stub in isolation (tests lens, nit) — true of any happy-path assertion; each is paired with a failure-case test proving the same code path actually detects the defect (Required Tests 2-5), which is where the real proof lives. `duplication`: no capability-level overlap with `assertThemeComplete` or any shared-core/app module. **Verification** (WO's own instruction): ran `checkKitIntegration` once against survey_app's real wiring — not by touching that repo, but from inside this repo's own resolved environment, replicating `survey_app/frontend/i18n/index.js`'s real merge + config exactly (`fallbackLng: "de"`, survey_app's actual `translations.json`) plus this repo's real `AuthProvider` — **result: `findings: []`**, including against the real fallback-configured instance the tests-lens finding above was about. Orchestrator's own scoped run: `tests/checkKitIntegration.test.jsx` + `tests/resolveErrorText.test.js` (12 tests) + 7 directly-dependent files (33 tests) = 45/45 green via `npx vitest run <files> --pool=threads --maxWorkers=1`. No rendered gate — library-only change, no running app to preview. **Released as `3.10.0`** (minor — new exported capability, per the WO's own instruction). Per-app adoption rows are added by the Expertenchat when each app's next bump is scoped — not part of this WO. |
38
44
  | UCM-AUTH-11 | [`UserListComponent` offers an admin the delete action on their own row](work-orders/UCM-AUTH-11.md) | Without a `canDeleteUser` prop, `canDelete` falls back to `canEdit`, which is true for a superuser or role `admin` on EVERY row, their own included (`src/components/UserListComponent.jsx` `defaultCanEdit`/`canDelete`, ~l.128-156); the backend (dcm) still allows an admin's generic self-delete. Default should never offer deleting one's own row. | 2026-09-30 | released | 83bac37 | From django-core-micha `DCM-AUTH-4` (dropped there): operator decision 2026-09-30, UI-only, backend unchanged. Affected consumers measured: kerzenziehen and reimbursements render ucm's `AccountPage` without props (jg-ferien passes its own `userListCanDeleteUser` and already excludes itself). Both apps pin ucm **2.41.3**, so reaching them needs a major bump (3.0.0 removed chart layout props) — tracked as its own follow-up, not this WO. Gate #1 approved 2026-09-30 (operator handover). Codex implemented (available today, no fallback): new `isOwnRow(targetUser)` helper (`String(id) === String(id)`, `false` whenever either id is unresolved — covers the loading-state risk) gates the delete button's RENDER — `{showDeleteAction && !isOwnRow(row) && (...)}` — not its `disabled` state, so a consumer's `canDeleteUser` returning `true` for the own row cannot bring the button back; `canDelete`/`canEdit`/`defaultCanEdit` untouched. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 3 raised · 1 accepted · worst accepted: nit**. Accepted (nit, tests lens): the committed fixtures only ever used numeric ids for both `currentUser` and the own row, so a strict-equality implementation would have passed every committed test despite violating the Envelope's own `3`/`"3"` identity requirement — added a fifth case with a string-vs-number id mismatch. Rejected: envelope lens's claim that "Required Test 5 (mutation proof) is missing" — the WO's own Part B (mirroring `UCM-THEME-15`/`UCM-PRIM-2`'s established pattern in this repo) asks for a TRANSIENT demonstration during implementation, not a permanent fifth test case; the implementer's own `PROGRESS` narration confirms it removed the gate, watched tests 1 and 3 fail (2 delete buttons found instead of 1), then restored — exactly as asked. Envelope lens's claim that "package.json remains 3.9.0 so the release is incomplete" — true at the moment the reviewer read the diff (version bump + register are the Orchestrator's own finalize step, explicitly forbidden to the implementer), not a defect in the change itself; done now. `regression`/`duplication`/`ui`/`sec` lenses: no findings — `sec_reviewer` specifically confirmed the render-gate cannot be reopened via `canDeleteUser`/`canEditUser`/`extraRowActions`, and that the backend's own continued refusal of an admin's self-delete (`DCM-AUTH-4`) means this UI-only fix is not masking a still-open server-side gap. Orchestrator's own scoped run: `tests/UserListComponent.test.jsx` 7/7 green + `tests/userScreenTranslations.test.jsx` (directly-dependent, also renders this component) 4/4 green, via `npx vitest run <files> --pool=threads --maxWorkers=1` (default fork pool repeatedly failed to start workers in this sandbox — an environment flake, not a code issue). No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.9.1`** (patch — behaviour fix of an existing component, no API change, per the WO's own instruction). `npm view @micha.bigler/ui-core-micha version` will read `3.9.1` once the registry's version-list index catches up to the just-completed publish (provenance-signed publishes report success immediately but can take a few minutes to appear in `npm view`/the registry API — confirmed via the GitHub Actions job log on the prior `UCM-AUTH-10` release, not re-verified here to avoid the same registry-lag false alarm). **Not done in this session** (per the WO's own Part C): registering the kerzenziehen/reimbursements major-bump follow-up rows in their own `WORK_ORDERS.md` registers — flagged for whoever next works in those repos. |
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useContext, useState } from 'react';
3
3
  import { Avatar, Box, Divider, IconButton, Menu, MenuItem, Typography, } from '@mui/material';
4
4
  import { useTheme } from '@mui/material/styles';
@@ -149,20 +149,21 @@ function extractAuthSignal(data) {
149
149
  return { code: null, i18nKey: null };
150
150
  }
151
151
  apiClient.interceptors.response.use((response) => response, async (error) => {
152
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
153
- const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
154
- const data = (_d = (_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : {};
152
+ var _a, _b, _c, _d, _e;
153
+ var _f, _g, _h, _j;
154
+ const status = (_f = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _f !== void 0 ? _f : null;
155
+ const data = (_g = (_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.data) !== null && _g !== void 0 ? _g : {};
155
156
  const { code, i18nKey } = extractAuthSignal(data);
156
157
  const isAuthStatus = status === 401 || status === 403;
157
158
  const isNotAuthenticated = code === "not_authenticated" || i18nKey === "auth.not_authenticated";
158
159
  // Reauthentication gate: allauth returns 401 + flows[{id:"reauthenticate"}]
159
160
  // when the session is stale before a sensitive operation (e.g. adding a passkey).
160
161
  // Show the password-confirm modal, wait for it to complete, then retry once.
161
- const flows = (_g = (_f = (_e = data === null || data === void 0 ? void 0 : data.data) === null || _e === void 0 ? void 0 : _e.flows) !== null && _f !== void 0 ? _f : data === null || data === void 0 ? void 0 : data.flows) !== null && _g !== void 0 ? _g : [];
162
+ const flows = (_j = (_h = (_c = data === null || data === void 0 ? void 0 : data.data) === null || _c === void 0 ? void 0 : _c.flows) !== null && _h !== void 0 ? _h : data === null || data === void 0 ? void 0 : data.flows) !== null && _j !== void 0 ? _j : [];
162
163
  const needsReauth = status === 401 &&
163
164
  Array.isArray(flows) &&
164
165
  flows.some((f) => f.id === "reauthenticate");
165
- if (needsReauth && !((_h = error.config) === null || _h === void 0 ? void 0 : _h._reauthRetried)) {
166
+ if (needsReauth && !((_d = error.config) === null || _d === void 0 ? void 0 : _d._reauthRetried)) {
166
167
  try {
167
168
  await requestReauth();
168
169
  return apiClient(Object.assign(Object.assign({}, error.config), { _reauthRetried: true }));
@@ -175,7 +176,7 @@ apiClient.interceptors.response.use((response) => response, async (error) => {
175
176
  // expect to handle 401 silently and must not trigger a redirect-on-mount.
176
177
  // Carried as an axios config property, so it never travels to the backend
177
178
  // (would otherwise trigger a CORS preflight on cross-origin requests).
178
- const skipRedirect = ((_j = error === null || error === void 0 ? void 0 : error.config) === null || _j === void 0 ? void 0 : _j.skipAuthRedirect) === true;
179
+ const skipRedirect = ((_e = error === null || error === void 0 ? void 0 : error.config) === null || _e === void 0 ? void 0 : _e.skipAuthRedirect) === true;
179
180
  if (isAuthStatus && isNotAuthenticated && !skipRedirect) {
180
181
  redirectToLoginOnce();
181
182
  }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Button, Stack, TextField } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ /**
6
+ * Message input. Send is disabled while a turn runs, for an archived chat and while the connection is down;
7
+ * Stop replaces it during a turn. Enter sends, Shift+Enter inserts a line break.
8
+ */
9
+ export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, sx }) {
10
+ const { t } = useTranslation();
11
+ const blocked = archived || !connected;
12
+ const canSend = !running && !blocked && value.trim().length > 0;
13
+ const submit = () => { if (canSend)
14
+ onSend === null || onSend === void 0 ? void 0 : onSend(); };
15
+ return (_jsxs(Stack, { direction: "row", alignItems: "flex-end", gap: 1, sx: Object.assign({ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider' }, sx), children: [_jsx(TextField, { multiline: true, maxRows: 6, fullWidth: true, size: "small", value: value, disabled: blocked, placeholder: t('Chat.message'), slotProps: { htmlInput: { 'aria-label': t('Chat.message') } }, onChange: (event) => onChange === null || onChange === void 0 ? void 0 : onChange(event.target.value), onKeyDown: (event) => {
16
+ var _a;
17
+ if (event.key === 'Enter' && !event.shiftKey && !((_a = event.nativeEvent) === null || _a === void 0 ? void 0 : _a.isComposing)) {
18
+ event.preventDefault();
19
+ submit();
20
+ }
21
+ } }), running ? _jsx(Button, { variant: "outlined", onClick: onStop, children: t('Chat.stop') }) : null, _jsx(Button, { variant: "contained", onClick: submit, disabled: !canSend, children: t('Chat.send') })] }));
22
+ }
23
+ export default ChatComposer;
@@ -0,0 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState } from 'react';
3
+ import { Badge, Box, Button, Collapse, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItem, ListItemButton, ListItemText, Menu, MenuItem, Stack, TextField } from '@mui/material';
4
+ import { MoreVert } from '@mui/icons-material';
5
+ import { useTranslation } from 'react-i18next';
6
+ export const CHAT_LIST_ROW_ACTION_SX = { width: 40, height: 40 };
7
+ export const CHAT_LIST_ARCHIVED_ACTION_SX = { justifyContent: 'space-between', px: 2 };
8
+ /**
9
+ * The chat list: active chats, then an "Archived" entry that expands the archived ones (read-only, restorable).
10
+ * Rename, Archive/Restore and Delete sit in each row's overflow menu; Delete asks for confirmation first.
11
+ */
12
+ export function ChatList({ chats = [], archivedChats = [], activeId, hasMore = false, onSelect, onNew, onRename, onArchive, onRestore, onDelete, onLoadMore, sx }) {
13
+ const { t } = useTranslation();
14
+ const [menu, setMenu] = useState(null); // { anchor, chat, archived }
15
+ const [dialog, setDialog] = useState(null); // { kind: 'rename' | 'delete', chat }
16
+ const [title, setTitle] = useState('');
17
+ const [showArchived, setShowArchived] = useState(false);
18
+ const openMenu = (event, chat, archived) => { event.stopPropagation(); setMenu({ anchor: event.currentTarget, chat, archived }); };
19
+ const closeMenu = () => setMenu(null);
20
+ const startRename = () => { setTitle(menu.chat.title || ''); setDialog({ kind: 'rename', chat: menu.chat }); closeMenu(); };
21
+ const startDelete = () => { setDialog({ kind: 'delete', chat: menu.chat }); closeMenu(); };
22
+ const toggleArchive = () => { var _a; (_a = (menu.archived ? onRestore : onArchive)) === null || _a === void 0 ? void 0 : _a(menu.chat); closeMenu(); };
23
+ const closeDialog = () => setDialog(null);
24
+ const confirmRename = () => { onRename === null || onRename === void 0 ? void 0 : onRename(dialog.chat, title.trim()); closeDialog(); };
25
+ const confirmDelete = () => { onDelete === null || onDelete === void 0 ? void 0 : onDelete(dialog.chat); closeDialog(); };
26
+ const row = (chat, archived) => (_jsx(ListItem, { disablePadding: true, secondaryAction: _jsx(IconButton, { edge: "end", "aria-label": t('Chat.actions'), onClick: (event) => openMenu(event, chat, archived), sx: CHAT_LIST_ROW_ACTION_SX, children: _jsx(MoreVert, { fontSize: "small" }) }), children: _jsx(ListItemButton, { selected: chat.id === activeId, onClick: () => onSelect === null || onSelect === void 0 ? void 0 : onSelect(chat), sx: { minHeight: 48, pr: 6 }, children: _jsx(ListItemText, { primary: chat.title || t('Chat.untitled'), slotProps: { primary: { noWrap: true } } }) }) }, chat.id));
27
+ return (_jsxs(Stack, { sx: Object.assign({ minWidth: 0, height: '100%', overflowY: 'auto' }, sx), component: "nav", "aria-label": t('Chat.chats'), children: [_jsx(Box, { sx: { p: 1.5 }, children: _jsx(Button, { fullWidth: true, variant: "contained", onClick: onNew, children: t('Chat.newChat') }) }), _jsx(List, { disablePadding: true, children: chats.map((chat) => row(chat, false)) }), hasMore ? _jsx(Button, { onClick: onLoadMore, children: t('Chat.loadMore') }) : null, archivedChats.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs(Button, { color: "inherit", onClick: () => setShowArchived((value) => !value), "aria-expanded": showArchived, sx: CHAT_LIST_ARCHIVED_ACTION_SX, children: [t('Chat.archived'), _jsx(Badge, { color: "default", badgeContent: archivedChats.length, sx: { mr: 1.5 } })] }), _jsx(Collapse, { in: showArchived, unmountOnExit: true, children: _jsx(List, { disablePadding: true, children: archivedChats.map((chat) => row(chat, true)) }) })] })) : null, _jsxs(Menu, { anchorEl: menu === null || menu === void 0 ? void 0 : menu.anchor, open: Boolean(menu), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startRename, children: t('Chat.rename') }), _jsx(MenuItem, { onClick: toggleArchive, children: (menu === null || menu === void 0 ? void 0 : menu.archived) ? t('Chat.restore') : t('Chat.archive') }), _jsx(MenuItem, { onClick: startDelete, children: t('Chat.delete') })] }), _jsxs(Dialog, { open: (dialog === null || dialog === void 0 ? void 0 : dialog.kind) === 'rename', onClose: closeDialog, fullWidth: true, maxWidth: "xs", children: [_jsx(DialogTitle, { children: t('Chat.rename') }), _jsx(DialogContent, { children: _jsx(TextField, { autoFocus: true, fullWidth: true, margin: "dense", value: title, onChange: (event) => setTitle(event.target.value), slotProps: { htmlInput: { 'aria-label': t('Chat.renameLabel'), maxLength: 120 } }, onKeyDown: (event) => { if (event.key === 'Enter' && title.trim())
28
+ confirmRename(); } }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closeDialog, children: t('Chat.cancel') }), _jsx(Button, { variant: "contained", disabled: !title.trim(), onClick: confirmRename, children: t('Chat.save') })] })] }), _jsxs(Dialog, { open: (dialog === null || dialog === void 0 ? void 0 : dialog.kind) === 'delete', onClose: closeDialog, fullWidth: true, maxWidth: "xs", children: [_jsx(DialogTitle, { children: t('Chat.delete') }), _jsx(DialogContent, { children: _jsx(DialogContentText, { children: t('Chat.deleteConfirm') }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closeDialog, children: t('Chat.cancel') }), _jsx(Button, { color: "error", variant: "contained", onClick: confirmDelete, children: t('Chat.delete') })] })] })] }));
29
+ }
30
+ export default ChatList;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useLayoutEffect, useRef } from 'react';
3
+ import { Alert, Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import MarkdownReply from './MarkdownReply';
6
+ export const CHAT_THREAD_TOOL_CHIP_SX = { alignSelf: 'flex-start' };
7
+ export const CHAT_THREAD_NOTICE_SX = { mb: 2 };
8
+ const selectionLine = (turn, t) => [turn.model, turn.effort && t(`Chat.effort.${turn.effort}`)].filter(Boolean).join(' · ');
9
+ const sameSelection = (a, b) => a.driver === b.driver && a.model === b.model && a.effort === b.effort;
10
+ function SourceChips({ sources }) {
11
+ const { t } = useTranslation();
12
+ if (!(sources === null || sources === void 0 ? void 0 : sources.length))
13
+ return null;
14
+ return (_jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.75, role: "list", "aria-label": t('Chat.sources'), children: sources.map((source, index) => (_jsx(Tooltip, { title: source.reference || '', describeChild: true, children: _jsx(Chip, { role: "listitem", size: "small", variant: "outlined", label: source.label }) }, `${source.label}-${index}`))) }));
15
+ }
16
+ function Answer({ turn, tool, onRetry }) {
17
+ const { t } = useTranslation();
18
+ const generating = turn.state === 'generating';
19
+ const failed = ['failed', 'cancelled'].includes(turn.state) && turn.error_code;
20
+ return (_jsxs(Stack, { spacing: 1, "aria-busy": generating, children: [turn.assistant_text ? _jsx(MarkdownReply, { text: turn.assistant_text }) : null, generating && !turn.assistant_text && !tool ? _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Chat.typing') }) : null, generating && tool ? _jsx(Chip, { size: "small", color: "primary", variant: "outlined", label: tool.label, sx: CHAT_THREAD_TOOL_CHIP_SX }) : null, _jsx(SourceChips, { sources: turn.sources }), !generating && selectionLine(turn, t) ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: selectionLine(turn, t) }) : null, turn.state === 'interrupted' ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Chip, { size: "small", color: "warning", label: t('Chat.interrupted') }), onRetry ? _jsx(Button, { size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null] })) : null, failed ? (_jsx(Alert, { severity: "error", action: onRetry ? _jsx(Button, { color: "inherit", size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null, children: t('Chat.answerIncomplete') })) : null] }));
21
+ }
22
+ /**
23
+ * The turns of one chat. The streaming answer is simply the last turn in state `generating`; the caller keeps its text
24
+ * up to date. `notice` is a connection or limit message shown under the last turn. `readOnly` (an archived chat) hides Retry.
25
+ */
26
+ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx }) {
27
+ var _a;
28
+ const { t } = useTranslation();
29
+ const end = useRef(null);
30
+ const scroller = useRef(null);
31
+ const heightBeforeOlder = useRef(null);
32
+ const pinned = useRef(true); // follow the stream only while the reader is at the bottom
33
+ const last = turns[turns.length - 1];
34
+ // Follow the stream, but only when new content arrives at the end (not when older turns are prepended).
35
+ useEffect(() => { var _a, _b; if (pinned.current)
36
+ (_b = (_a = end.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(_a, { block: 'end' }); }, [last === null || last === void 0 ? void 0 : last.id, last === null || last === void 0 ? void 0 : last.assistant_text, last === null || last === void 0 ? void 0 : last.state]);
37
+ // Keep the reader's position when older turns are prepended.
38
+ useLayoutEffect(() => {
39
+ const el = scroller.current;
40
+ if (el && heightBeforeOlder.current !== null)
41
+ el.scrollTop += el.scrollHeight - heightBeforeOlder.current;
42
+ heightBeforeOlder.current = null;
43
+ }, [(_a = turns[0]) === null || _a === void 0 ? void 0 : _a.id]);
44
+ const loadingOlder = useRef(false); // one request at a time, also across rapid scroll events
45
+ const loadOlder = async () => {
46
+ var _a;
47
+ var _b;
48
+ if (loadingOlder.current)
49
+ return;
50
+ loadingOlder.current = true;
51
+ heightBeforeOlder.current = (_b = (_a = scroller.current) === null || _a === void 0 ? void 0 : _a.scrollHeight) !== null && _b !== void 0 ? _b : null;
52
+ try {
53
+ await (onLoadOlder === null || onLoadOlder === void 0 ? void 0 : onLoadOlder());
54
+ }
55
+ finally {
56
+ loadingOlder.current = false;
57
+ }
58
+ };
59
+ return (_jsxs(Box, { ref: scroller, onScroll: (event) => {
60
+ const el = event.currentTarget;
61
+ pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
62
+ if (hasOlder && !olderBusy && el.scrollTop <= 40)
63
+ loadOlder();
64
+ }, role: "log", "aria-live": "polite", "aria-label": t('Chat.thread'), sx: Object.assign({ minHeight: 0, flex: 1, overflowY: 'auto', p: { xs: 1.5, md: 2.5 } }, sx), children: [hasOlder ? (_jsx(Box, { sx: { textAlign: 'center', mb: 2 }, children: _jsx(Button, { size: "small", onClick: loadOlder, disabled: olderBusy, children: t('Chat.loadOlder') }) })) : null, turns.length === 0 ? (_jsx(Typography, { variant: "h6", color: "text.secondary", align: "center", sx: { mt: 6 }, children: t('Chat.emptyTitle') })) : null, turns.map((turn, index) => {
65
+ var _a;
66
+ const previous = turns[index - 1];
67
+ return (_jsxs(Box, { sx: { mb: 3 }, children: [previous && !sameSelection(previous, turn) ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { mb: 2, color: 'text.secondary' }, role: "separator", children: [_jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } }), _jsx(Typography, { variant: "caption", children: t('Chat.modelChanged', { model: selectionLine(turn, t) }) }), _jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } })] })) : null, _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end', mb: 1.5 }, children: _jsx(Typography, { sx: { maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }, children: turn.user_text }) }), _jsx(Answer, { turn: turn, tool: index === turns.length - 1 ? tool : null, onRetry: readOnly ? undefined : onRetry })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
68
+ }), notice ? (_jsx(Alert, { severity: notice.severity || 'warning', sx: CHAT_THREAD_NOTICE_SX, action: notice.action ? _jsx(Button, { color: "inherit", size: "small", onClick: notice.action.onClick, children: notice.action.label }) : null, children: notice.text })) : null, _jsx("div", { ref: end })] }));
69
+ }
70
+ export default ChatThread;
@@ -0,0 +1,218 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { Alert, Box, Button, Chip, Drawer, Stack, Typography, useMediaQuery, useTheme } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { createChatApi } from './api';
6
+ import { useChatSocket } from './useChatSocket';
7
+ import ChatList from './ChatList';
8
+ import ChatThread from './ChatThread';
9
+ import ChatComposer from './ChatComposer';
10
+ import ModelPicker from './ModelPicker';
11
+ const PENDING_ID = 'pending';
12
+ const bySeq = (a, b) => { var _a, _b; return ((_a = a.seq) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER) - ((_b = b.seq) !== null && _b !== void 0 ? _b : Number.MAX_SAFE_INTEGER); };
13
+ const chronological = (turns) => [...(turns || [])].sort(bySeq);
14
+ const isGenerating = (turn) => turn.state === 'generating';
15
+ /** Replaces the turn that was generating (or has the same id) with the final one; appends it when neither exists. */
16
+ const settleTurn = (turns, final) => {
17
+ const index = turns.findIndex((turn) => turn.id === final.id || isGenerating(turn));
18
+ if (index < 0)
19
+ return chronological([...turns, final]);
20
+ return [...turns.slice(0, index), final, ...turns.slice(index + 1)];
21
+ };
22
+ const failGenerating = (turns, code) => turns.map((turn) => (isGenerating(turn)
23
+ ? Object.assign(Object.assign({}, turn), { state: 'failed', error_code: code === 'quota_exceeded' ? null : code }) : turn));
24
+ const appendDelta = (turns, text) => {
25
+ const index = turns.findLastIndex(isGenerating);
26
+ if (index < 0)
27
+ return turns;
28
+ return turns.map((turn, i) => (i === index ? Object.assign(Object.assign({}, turn), { assistant_text: `${turn.assistant_text || ''}${text}` }) : turn));
29
+ };
30
+ /**
31
+ * The complete chat surface: chat list, thread, composer and model picker for one user.
32
+ *
33
+ * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
34
+ * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
35
+ */
36
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx }) {
37
+ const { t } = useTranslation();
38
+ const theme = useTheme();
39
+ const narrow = useMediaQuery(theme.breakpoints.down('md'));
40
+ const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
41
+ const [status, setStatus] = useState('loading'); // loading | available | not_configured | error
42
+ const [catalogue, setCatalogue] = useState({ entries: [], default: null });
43
+ const [chats, setChats] = useState([]);
44
+ const [archived, setArchived] = useState([]);
45
+ const [nextChats, setNextChats] = useState(null);
46
+ const [chat, setChat] = useState(null);
47
+ const [turns, setTurns] = useState([]);
48
+ const [olderCursor, setOlderCursor] = useState(null);
49
+ const [olderBusy, setOlderBusy] = useState(false);
50
+ const [tool, setTool] = useState(null);
51
+ const [draft, setDraft] = useState('');
52
+ const [problem, setProblem] = useState(null); // an error code or close reason, shown as a notice
53
+ const [listOpen, setListOpen] = useState(false);
54
+ const [pickerOpen, setPickerOpen] = useState(false);
55
+ const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
56
+ const running = turns.some(isGenerating);
57
+ const isArchived = Boolean(chat === null || chat === void 0 ? void 0 : chat.archived_at);
58
+ const selection = (chat === null || chat === void 0 ? void 0 : chat.selection) || catalogue.default;
59
+ const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
60
+ const loadLists = useCallback(async () => {
61
+ const [active, old] = await Promise.all([api.listChats(), api.listChats({ archived: true })]);
62
+ setChats(active.results || []);
63
+ setNextChats(active.next || null);
64
+ setArchived(old.results || []);
65
+ }, [api]);
66
+ useEffect(() => {
67
+ let live = true;
68
+ api.getStatus()
69
+ .then(async (state) => {
70
+ if (state.status !== 'available') {
71
+ if (live)
72
+ setStatus(state.status);
73
+ return;
74
+ }
75
+ const [entries] = await Promise.all([api.getCatalogue(), loadLists()]);
76
+ if (live) {
77
+ setStatus(state.status);
78
+ setCatalogue(entries);
79
+ }
80
+ })
81
+ .catch(() => { if (live)
82
+ setStatus('error'); });
83
+ return () => { live = false; };
84
+ }, [api, loadLists]);
85
+ useEffect(() => {
86
+ setTurns([]);
87
+ setOlderCursor(null);
88
+ setTool(null);
89
+ setProblem(null);
90
+ setDraft('');
91
+ setOpenedOnce(false);
92
+ if (!(chat === null || chat === void 0 ? void 0 : chat.id))
93
+ return undefined;
94
+ let live = true;
95
+ api.listTurns(chat.id)
96
+ .then((page) => { if (live) {
97
+ setTurns(chronological(page.results));
98
+ setOlderCursor(page.next || null);
99
+ } })
100
+ .catch(() => { if (live)
101
+ setProblem('load_error'); });
102
+ return () => { live = false; };
103
+ }, [api, chat === null || chat === void 0 ? void 0 : chat.id]);
104
+ const onFrame = useCallback((frame, chatId) => {
105
+ if (chatId !== (chat === null || chat === void 0 ? void 0 : chat.id))
106
+ return;
107
+ if (frame.type === 'history')
108
+ setTurns(chronological(frame.turns));
109
+ else if (frame.type === 'delta')
110
+ setTurns((current) => appendDelta(current, frame.text || ''));
111
+ else if (frame.type === 'tool')
112
+ setTool(frame.phase === 'start' ? { label: frame.label, name: frame.name } : null);
113
+ else if (frame.type === 'turn' && frame.turn) {
114
+ setTool(null);
115
+ setTurns((current) => settleTurn(current, frame.turn));
116
+ if (frame.turn.error_code === 'quota_exceeded')
117
+ setProblem('quota_exceeded');
118
+ }
119
+ else if (frame.type === 'error') {
120
+ setTool(null);
121
+ setTurns((current) => failGenerating(current, frame.code));
122
+ setProblem(frame.code);
123
+ }
124
+ }, [chat === null || chat === void 0 ? void 0 : chat.id]);
125
+ const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
126
+ const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
127
+ setProblem(reason); }, []);
128
+ const socket = useChatSocket(chat === null || chat === void 0 ? void 0 : chat.id, { wsUrl, onFrame, onOpen, onClose });
129
+ const guarded = (action) => async (...args) => {
130
+ try {
131
+ return await action(...args);
132
+ }
133
+ catch (_a) {
134
+ setProblem('action_error');
135
+ return null;
136
+ }
137
+ };
138
+ const selectChat = (next) => { setChat(next); setListOpen(false); };
139
+ const createChat = guarded(async () => {
140
+ const created = await api.createChat(scope ? { scope } : {});
141
+ setChats((current) => [created, ...current]);
142
+ selectChat(created);
143
+ });
144
+ const updateChat = guarded(async (target, patch) => {
145
+ const updated = await api.updateChat(target.id, patch);
146
+ if ((chat === null || chat === void 0 ? void 0 : chat.id) === target.id)
147
+ setChat(updated);
148
+ await loadLists();
149
+ });
150
+ const deleteChat = guarded(async (target) => {
151
+ await api.deleteChat(target.id);
152
+ setChats((current) => current.filter((item) => item.id !== target.id));
153
+ setArchived((current) => current.filter((item) => item.id !== target.id));
154
+ if ((chat === null || chat === void 0 ? void 0 : chat.id) === target.id)
155
+ setChat(null);
156
+ });
157
+ const loadMoreChats = guarded(async () => {
158
+ const page = await api.listChats({ cursor: nextChats });
159
+ setChats((current) => [...current, ...(page.results || [])]);
160
+ setNextChats(page.next || null);
161
+ });
162
+ const loadOlder = guarded(async () => {
163
+ setOlderBusy(true);
164
+ try {
165
+ const page = await api.listTurns(chat.id, { cursor: olderCursor });
166
+ setTurns((current) => chronological([...(page.results || []), ...current]));
167
+ setOlderCursor(page.next || null);
168
+ }
169
+ finally {
170
+ setOlderBusy(false);
171
+ }
172
+ });
173
+ const startTurn = (message) => {
174
+ if (!socket.send({ type: 'send', message }))
175
+ return false;
176
+ setProblem(null);
177
+ setTurns((current) => [...current, {
178
+ id: PENDING_ID, user_text: message, assistant_text: '', state: 'generating', sources: [],
179
+ driver: activeEntry === null || activeEntry === void 0 ? void 0 : activeEntry.driver, model: (activeEntry === null || activeEntry === void 0 ? void 0 : activeEntry.label) || (activeEntry === null || activeEntry === void 0 ? void 0 : activeEntry.model), effort: selection === null || selection === void 0 ? void 0 : selection.effort,
180
+ }]);
181
+ return true;
182
+ };
183
+ const send = () => { if (startTurn(draft))
184
+ setDraft(''); };
185
+ const retry = (turn) => {
186
+ if (!socket.connected)
187
+ return;
188
+ setTurns((current) => current.filter((item) => item.id !== turn.id));
189
+ startTurn(turn.user_text);
190
+ };
191
+ const noticeFor = () => {
192
+ const texts = {
193
+ session_ended: 'Chat.sessionEnded', access_lost: 'Chat.accessLost', rate_limited: 'Chat.rateLimited',
194
+ load_error: 'Chat.loadError', action_error: 'Chat.actionError',
195
+ };
196
+ if (problem === 'quota_exceeded') {
197
+ return { text: t('Chat.usageLimit', { model: (activeEntry === null || activeEntry === void 0 ? void 0 : activeEntry.label) || (activeEntry === null || activeEntry === void 0 ? void 0 : activeEntry.model) || '' }), action: { label: t('Chat.changeModel'), onClick: () => setPickerOpen(true) } };
198
+ }
199
+ if (problem === 'connection_lost')
200
+ return { text: t('Chat.connectionLost'), action: { label: t('Chat.reconnect'), onClick: socket.reconnect } };
201
+ if (problem === 'not_configured')
202
+ return null;
203
+ if (texts[problem])
204
+ return { text: t(texts[problem]), severity: 'warning' };
205
+ return null;
206
+ };
207
+ const list = (_jsx(ChatList, { chats: chats, archivedChats: archived, activeId: chat === null || chat === void 0 ? void 0 : chat.id, hasMore: Boolean(nextChats), onSelect: selectChat, onNew: createChat, onRename: (target, title) => updateChat(target, { title }), onArchive: (target) => updateChat(target, { archived: true }), onRestore: (target) => updateChat(target, { archived: false }), onDelete: deleteChat, onLoadMore: loadMoreChats }));
208
+ const unavailable = status === 'not_configured' || problem === 'not_configured';
209
+ if (status === 'loading')
210
+ return _jsx(Box, { sx: sx, "aria-busy": "true" });
211
+ if (status === 'error')
212
+ return _jsx(Box, { sx: sx, children: _jsx(Alert, { severity: "error", children: t('Chat.loadError') }) });
213
+ if (unavailable) {
214
+ return (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 1, sx: Object.assign({ minHeight: 320, p: 3, textAlign: 'center' }, sx), children: [_jsx(Typography, { variant: "h6", children: t('Chat.unavailable') }), _jsx(Typography, { color: "text.secondary", children: t('Chat.noModel') })] }));
215
+ }
216
+ return (_jsxs(Box, { sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : 'minmax(240px, 320px) minmax(0, 1fr)', height: '100%', minHeight: 480, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden', bgcolor: 'background.paper' }, sx), children: [narrow ? null : _jsx(Box, { sx: { borderRight: 1, borderColor: 'divider', minHeight: 0 }, children: list }), _jsxs(Stack, { sx: { minWidth: 0, minHeight: 0 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }, children: [narrow ? _jsx(Button, { color: "inherit", onClick: () => setListOpen(true), children: t('Chat.chats') }) : null, _jsx(Typography, { noWrap: true, sx: { flex: 1, fontWeight: 600 }, children: chat ? (chat.title || t('Chat.untitled')) : '' }), socket.status === 'connecting' && openedOnce ? _jsx(Chip, { size: "small", color: "warning", variant: "outlined", label: t('Chat.reconnecting') }) : null, isArchived ? _jsx(Chip, { size: "small", label: t('Chat.archived') }) : null, isArchived ? _jsx(Button, { size: "small", onClick: () => updateChat(chat, { archived: false }), children: t('Chat.restore') }) : null, chat ? (_jsx(ModelPicker, { entries: catalogue.entries, selection: selection, disabled: isArchived || running, open: pickerOpen, onOpenChange: setPickerOpen, onChange: (next) => updateChat(chat, { selection: next }) })) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, tool: tool, notice: noticeFor(), hasOlder: Boolean(olderCursor), olderBusy: olderBusy, onLoadOlder: loadOlder, onRetry: retry, readOnly: isArchived }), isArchived ? _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { px: 2, pb: 1 }, children: t('Chat.archivedReadOnly') }) : null, _jsx(ChatComposer, { value: draft, onChange: setDraft, onSend: send, onStop: () => socket.send({ type: 'stop' }), running: running, archived: isArchived, connected: socket.connected })] })) : (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 2, sx: { flex: 1, p: 3 }, children: [_jsx(Typography, { color: "text.secondary", align: "center", children: t('Chat.selectChat') }), _jsx(Button, { variant: "contained", onClick: createChat, children: t('Chat.newChat') })] }))] }), narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
217
+ }
218
+ export default ChatWorkspace;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { memo } from 'react';
3
+ import { Box } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import ReactMarkdown from 'react-markdown';
6
+ import remarkGfm from 'remark-gfm';
7
+ export const MARKDOWN_LIMIT = 200000;
8
+ const allowed = ['p', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'del', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'a', 'img'];
9
+ const plainText = (children) => (Array.isArray(children) ? children : [children]).map((child) => typeof child === 'string' ? child : '').join('');
10
+ function MarkdownReply({ text = '' }) {
11
+ const { t } = useTranslation();
12
+ if (text.length > MARKDOWN_LIMIT)
13
+ return _jsx(Box, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: text });
14
+ return _jsx(Box, { sx: { minWidth: 0, overflowWrap: 'anywhere', '& p': { my: 1 }, '& table': { borderCollapse: 'collapse' }, '& th,& td': { border: 1, borderColor: 'divider', p: 1 }, '& pre': { overflowX: 'auto', bgcolor: 'action.hover', p: 1, borderRadius: 1 }, '& blockquote': { borderLeft: 2, borderColor: 'divider', pl: 1, color: 'text.secondary' } }, children: _jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], allowedElements: allowed, unwrapDisallowed: true, components: {
15
+ a: ({ href, children }) => { const label = plainText(children); return _jsxs("span", { children: [children, href && href !== label ? ' (' + href + ')' : ''] }); },
16
+ img: ({ alt, src }) => _jsx("span", { children: alt || src || '' }),
17
+ table: ({ children }) => _jsx(Box, { role: "region", tabIndex: 0, "aria-label": t('Chat.markdownTable'), sx: { overflowX: 'auto', maxWidth: '100%' }, children: _jsx("table", { children: children }) }),
18
+ pre: ({ children }) => _jsx("pre", { tabIndex: 0, "aria-label": t('Chat.markdownCode'), children: children }),
19
+ }, children: text }) });
20
+ }
21
+ export default memo(MarkdownReply);