@micha.bigler/ui-core-micha 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/WORK_ORDERS.md CHANGED
@@ -31,4 +31,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
31
31
  | DX-1 | Minimal Vite dev harness for ucm components | Give ucm a way to **render** its components during development: a `dev` script starting a Vite dev page that mounts surfaces with the repo's MUI theme, i18n and router context, driven by fixtures through an injectable mock API/realtime adapter, with light/dark and viewport switching. Harness code stays out of the published package (not exported, excluded from the build, zero runtime deps). | 2026-07-31 | done | 7ea7b26 | New `DX-*` prefix (ucm-local). Envelope: [work-orders/DX-1.md](work-orders/DX-1.md). Tier 1, but **adds devDependencies → approval-gated**; operator approval granted 2026-07-31 as part of the MSG-3 scoping decision. Gap found while scoping MSG-3: `package.json` had only `build` (tsc) and `test` (vitest) — no Storybook, no demo, no dev page, so every ucm surface ever shipped (auth, onboarding, notifications, charts) was verified only in jsdom or after a consuming app pinned it. Storybook deliberately not chosen (config surface vs. a repo with no story-writing habit). **Runs before MSG-3**, which is ~3700 LOC of UI with no consumer until MSG-5 and an operator-granted redesign licence that is only iterable if the result can be looked at. No version bump, no publish (dev-only). **Implemented (Codex, one pass):** `pnpm dev` (`vite`) boots a harness (`dev/main.jsx`) with MUI theme/i18n/router, light/dark + mobile/tablet/desktop switching, entries for a notifications surface (`NotificationBell`) and a standalone chart (`BarChart`, proving single-component mounting), plus a dedicated realtime-adapter demo entry. Mock transport (`dev/mockTransport.jsx`) overrides `apiClient`'s axios adapter for REST and provides a `RealtimeContext.Provider` for context-consuming components. Independent `reviewer`: one P2 — `NotificationsProvider` opens its own WebSocket internally rather than reading one from context, so the notifications entry opened a real, always-failing socket; fixed by the orchestrator with a `MockWebSocket` class registered as `window.WebSocket` while the harness is mounted, covering both consumption paths. Verified in the browser (both entries, dispatch button, no console errors/WS connection attempts). Build clean, `dist/` unaffected, `dependencies`/`peerDependencies` untouched (only `devDependencies` + `scripts.dev`), 104/104 tests green. |
32
32
  | MSG-3 | ucm messaging surfaces (full parity) | `MessagingProvider` + API adapter + normalized cache, `ConversationList`/`Thread`/`Composer`/`ReadTicks`/`ReactionBar`/`PollCard`/`AttachmentList` + config/preferences and launchers, on the Layer-1 transport (`subscribe('messaging', …)`, `event_id` dedup, reconnect refetch); opaque-cursor pagination with reverse infinite scroll and optimistic send from day one; de/en/fr. Host apps supply routing/display/scope pickers, not a forked state machine. | 2026-07-31 | done | affa5ac, 5e87315, a12c9c6, c7100b1, 95b69d4, 974e24c, 4369400 | ucm-side mirror of the canonical dcm `MSG-3` row. Envelope: [work-orders/MSG-3.md](work-orders/MSG-3.md). Tier 2, 5 proposed chunks (register's "3-5" treated as a floor — jg's parity target is ~3700 LOC excluding tests, `Thread.jsx` alone 2470). Two operator decisions 2026-07-31: (1) **redesign permitted** for layout/composition/interaction, but the design's "No jg feature is lost" stays binding and the WO must ship a written **deviation list** — consequence: MSG-5 becomes a visible change for jg users and needs its own UX review; (2) validation runs through the **DX-1** harness, which is therefore a precondition. Carries `ui_reviewer` at WO end in addition to the per-chunk independent `reviewer`. **Decomposition is a binding requirement, not a preference:** jg's UI is one 2470-LOC component with a 2173-LOC test file, and reproducing that shape is a scope violation even with every feature present — each named component is separately exported, independently mountable and independently testable, state lives in the provider rather than being prop-drilled through `Thread`, every component gets its own DX-1 harness entry (the forcing function — a monolith cannot be mounted piecewise), and any component file above ~400 LOC must be justified in that chunk's review. Depends on dcm 2.36.1 (MSG-2 + MSG-2b). **All 5 chunks implemented, independently reviewed and committed** (chunk 1: additive Layer-1 `onReconnect` extension per operator decision, provider/cache/transport; chunk 2: conversation list + launchers, R1 menu-anchor fix, R2 per-kind icon, R3 broadcast-launcher visibility; chunk 3: Thread/MessageBubble/ReadTicks, R1 N+1 ReadTicks gating fix; chunk 4: Composer/attachments, R1 `extractApiErrorMessage` for dcm's `{files:[...]}` shape, R2 optimistic sender, R3 own-message pending-ReadTicks fix, R4 i18n diacritics; chunk 5: reactions/polls/scope-config/announcements, R1-R4 + a follow-up verification pass V1-V3 including building jg's previously-missing `AnnouncementDialog` parity per explicit operator decision). **WO-end `ui_reviewer`** (independent, full assembled feature not one chunk's diff): found U1-U5, all P2/P3, none blocking — raw-`<button>` reply action instead of MUI `Button` (U1), PollCard packed into the same wrapping flex row as reaction chips instead of its own full-width row (U2), MessagingScopeConfig's text-only loading state instead of the package's `CircularProgress` convention (U3), a raw inline-style attachment thumbnail instead of theme spacing (U4), and `MessageBubble`/`ConversationLaunchers` missing their required DX-1 harness entries (U5). All five fixed by the orchestrator (`974e24c`), re-verified 139/139 tests + clean build. Translation coverage, dark-mode tokens, deviation-doc accuracy and primitive reuse checked clean, no findings. Published: **ucm 2.16.0** (npm, CI-gated on push to `main`, `4369400`). No CHANGELOG.md in this repo (same as noted in NOTIF-13/NOTIF-12) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
33
33
  | MSG-3b | Close the MSG-3 parity and contract gaps | Capability-checklist WO closing what MSG-3 missed: the entire unread lifecycle (mark-read never called — badges once raised never clear), message edit/delete + action menu, the DM launcher (`createDirectConversation` had no caller — the case MSG-2b unblocked in the backend), broken reply grouping (`serialize_message` emits `reply_to_id`, `Thread.jsx:40,62` reads `reply_to`, so every server-loaded reply renders as a root), quoted reply previews + jump-to-original, copy, client-side image compression, staged-image previews, upload progress, composer emoji insertion, relative timestamps, active-row highlight, announcement deep-link rendering. Plus a contract-conformance test and a corrected deviation list. | 2026-07-31 | done | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01, 9748aca | Extends the landed MSG-3 (2.16.0), convention as MSG-2b/NOTIF-8b. Envelope: [work-orders/MSG-3b.md](work-orders/MSG-3b.md). Tier 2. **Origin: a three-way audit** (jg capability baseline / ucm wired-vs-adapter-only inventory / dcm contract-coverage sweep) run after MSG-3 was marked done. MSG-3 had 139/139 green, five per-chunk reviews and a clean `ui_reviewer`, and still missed all of it — **because both sides were tested against themselves**: dcm against its services, ucm against a mock whose surface mirrors the provider rather than the contract, so an endpoint with no caller is structurally invisible. Second cause: "no jg feature is lost" was made binding without anyone writing the feature list first, and the deviation list meant to prove parity was authored by the party doing the work; its "Final parity confirmation" section claims completeness and is demonstrably false. This WO's checklist IS the spec (59 rows, each OK/DEV/MISSING/BROKEN/PARTIAL/BLOCKED; the review corrected one false OK — row 5, unread never live-updates at all); the contract-conformance test is the systemic fix. **Rows 38, 51-53, 56-58 are BLOCKED on dcm** (`MSG-2c`, not yet cut): no `serialize_poll` exists at all so `PollCard` reads fields no endpoint returns — and the design specifies no poll read path either, so `messaging-platform.md` must be amended first; `services.py` has 3 `_publish` call sites for 12 designed frames, so reaction/poll live sync cannot work; `serialize_conversation` omits `last_message`, so the list preview is permanently empty. Carries `ui_reviewer` at WO end, which must check the corrected deviation list against the checklist row by row. **All 6 chunks implemented, independently reviewed and committed** (chunk 1: unread lifecycle end-to-end + contract-conformance test, own-fix for a markThreadRead failure silently blocking thread-open; chunk 2: `reply_to`/`reply_to_id` fix + quoting/jump-to-original, row 27 found and recorded BLOCKED (no dcm thread-receipt read path) after an independent-review P1 caught it being silently dropped first time; chunk 3: message action menu/edit/delete/copy with a host-supplied moderator capability prop (dcm exposes no server-side capability field), fixed an undefined-vs-undefined author-match bug; chunk 4: `DirectMessageLauncher` wiring `createDirectConversation`, fixed jest-dom-matcher and MUI-dialog-transition-timing test issues after Codex's own verification pass died without a `RESULT:` line; chunk 5: composer shortcuts/previews/compression/emoji, fixed Codex settling for indeterminate-only upload progress by threading real `onUploadProgress` through the adapter as the WO asked, fixed missing diacritics in new translations (same bug class caught once before in MSG-3), corrected an unverified poll/attachment-mutual-exclusivity claim before it could land in the deviation doc; chunk 6: list/timeline polish (relative timestamps, auto-scroll, announcement links, DM sender-suppression, read-receipt popover) plus the full deviation-doc rewrite against all 59 checklist rows — fixed a repeat of an already-once-fixed raw-interactive-element pattern, corrected an overclaiming doc line, fixed a stale-ref edge case in auto-scroll). Row 42 (managed all-vs-team) also verified genuinely BLOCKED — dcm has `external_key` but never serializes it. **WO-end `ui_reviewer`** (independent, row-by-row cross-check of `docs/messaging-deviations.md` against landed code for all 59 checklist rows, per the WO's binding "the review that signed off the false completeness claim is the one this WO exists to correct" instruction): no findings — every OK/DEV/BLOCKED row verified against actual code, not against the doc's own claims; translation coverage, DX-1 harness coverage, design-system/dark-mode/reuse all checked clean. Published: **ucm 2.17.0** (npm, CI-gated on push to `main`, `9748aca`). No CHANGELOG.md in this repo (same as NOTIF-12/NOTIF-13/MSG-3) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
34
- | MSG-3c | Deliver the rows MSG-3b left blocked against a stale dcm version | Seven checklist rows (38, 51-53, 56-58) that MSG-3b correctly refused to fake against dcm 2.36.1, and which dcm 2.37.0 made buildable **while MSG-3b was still running**: real last-message preview + list reorder/preview refresh, full `PollCard` rendering (question / options / `vote_count` / `voters` / closed state), and the `reaction` / `poll_updated` frame handlers that stop being dead code. Plus the deviation-doc correction and a conformance-test extension requiring every BLOCKED entry to carry a dependency version pin. | 2026-07-31 | in-review | 84cc3c2, 5dd4d3a, 733588a | Envelope: [work-orders/MSG-3c.md](work-orders/MSG-3c.md); canonical row in dcm's register. Extends the landed MSG-3b (2.17.0). **Root cause — third occurrence of one pattern in this workstream:** the WO-end `ui_reviewer` cross-checked all 59 rows against *ucm's* landed code and took the dependency's state from the WO text instead of reading dcm, so seven rows closed as "blocked on a future dcm work order" that had already shipped. New rule this WO adds: a BLOCKED row is re-verified against the live dependency **at WO close, not at WO start**, and every BLOCKED entry names the version it was blocked at. MSG-3b already did the version-pinning unprompted — that pin is the only reason the staleness was a two-minute check rather than surfacing at MSG-5. Rows 27 and 42 stay correctly BLOCKED, on dcm `MSG-2d`. Highest latent risk: rows 56/57 are handler paths that have **never** run against a real frame, so treat them as new code under test rather than as working code being unblocked; and poll tests must use server-shaped payloads (`serialize_poll` output), since client-shaped fixtures are what let the `reply_to_id` bug survive MSG-3. **Codex was out of credits for this entire WO** (verified: every invocation returned only a workspace-credits error, no work done) — all three chunks implemented directly by the orchestrator per the Codex→Claude fallback rule, with mandatory independent review on every chunk (author ≠ reviewer). **Chunk 1** (rows 38/58): fixed a real bug found while scoping — `ConversationList` read `last_message.body`, but dcm's `serialize_last_message` only ever returns `.excerpt` — plus a defensive fix to `applyFrame`'s `message_edited` keying (reviewer found my initial bug analysis incomplete: dcm always attaches a full `frame.message`, so the fix is a no-op against the real wire contract but kept as genuine defensive robustness; the regression test was rewritten to use the real enriched frame shape instead of a fabricated minimal one). **Chunk 2** (rows 51-53): fixed `PollCard` reading non-existent `created_by`/`selected`/`voted` fields (real fields: `created_by_id`, `voted_option_ids` — the latter present only on the vote/create/close REST responses, never in `serialize_poll` or the `poll_updated` frame); found and fixed the same "frame wholesale-replaces viewer-specific state" defect class in `poll_updated`'s own handler ahead of chunk 3's explicit scope. **Chunk 3** (rows 56/57 + WO deliverables): found and fixed the identical defect in the `reaction` frame handler and `toggleReaction`'s REST-confirm path (dcm's `serialize_reactions` never returns a per-viewer `reacted` flag; both call sites were wholesale-replacing and silently wiping known reacted state); extended the contract-conformance test with the WO's new BLOCKED-version-pin rule plus a required negative-case test; rewrote `docs/messaging-deviations.md` (rows 38/51/53/56-58 → OK, row 52 → DEV, rows 27/42 stay BLOCKED with the new version pin, substance untouched). Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass (per the WO's changed instruction — cross-check BLOCKED rows against dcm's *current* version) and the version-bump/publish step are still outstanding. |
34
+ | MSG-3c | Deliver the rows MSG-3b left blocked against a stale dcm version | Seven checklist rows (38, 51-53, 56-58) that MSG-3b correctly refused to fake against dcm 2.36.1, and which dcm 2.37.0 made buildable **while MSG-3b was still running**: real last-message preview + list reorder/preview refresh, full `PollCard` rendering (question / options / `vote_count` / `voters` / closed state), and the `reaction` / `poll_updated` frame handlers that stop being dead code. Plus the deviation-doc correction and a conformance-test extension requiring every BLOCKED entry to carry a dependency version pin. | 2026-07-31 | done | 84cc3c2, 5dd4d3a, 733588a, 170ce1e | Envelope: [work-orders/MSG-3c.md](work-orders/MSG-3c.md); canonical row in dcm's register. Extends the landed MSG-3b (2.17.0). **Root cause — third occurrence of one pattern in this workstream:** the WO-end `ui_reviewer` cross-checked all 59 rows against *ucm's* landed code and took the dependency's state from the WO text instead of reading dcm, so seven rows closed as "blocked on a future dcm work order" that had already shipped. New rule this WO adds: a BLOCKED row is re-verified against the live dependency **at WO close, not at WO start**, and every BLOCKED entry names the version it was blocked at. MSG-3b already did the version-pinning unprompted — that pin is the only reason the staleness was a two-minute check rather than surfacing at MSG-5. Rows 27 and 42 stay correctly BLOCKED, on dcm `MSG-2d`. Highest latent risk: rows 56/57 are handler paths that have **never** run against a real frame, so treat them as new code under test rather than as working code being unblocked; and poll tests must use server-shaped payloads (`serialize_poll` output), since client-shaped fixtures are what let the `reply_to_id` bug survive MSG-3. **Codex was out of credits for this entire WO** (verified: every invocation returned only a workspace-credits error, no work done) — all three chunks implemented directly by the orchestrator per the Codex→Claude fallback rule, with mandatory independent review on every chunk (author ≠ reviewer). **Chunk 1** (rows 38/58): fixed a real bug found while scoping — `ConversationList` read `last_message.body`, but dcm's `serialize_last_message` only ever returns `.excerpt` — plus a defensive fix to `applyFrame`'s `message_edited` keying (reviewer found my initial bug analysis incomplete: dcm always attaches a full `frame.message`, so the fix is a no-op against the real wire contract but kept as genuine defensive robustness; the regression test was rewritten to use the real enriched frame shape instead of a fabricated minimal one). **Chunk 2** (rows 51-53): fixed `PollCard` reading non-existent `created_by`/`selected`/`voted` fields (real fields: `created_by_id`, `voted_option_ids` — the latter present only on the vote/create/close REST responses, never in `serialize_poll` or the `poll_updated` frame); found and fixed the same "frame wholesale-replaces viewer-specific state" defect class in `poll_updated`'s own handler ahead of chunk 3's explicit scope. **Chunk 3** (rows 56/57 + WO deliverables): found and fixed the identical defect in the `reaction` frame handler and `toggleReaction`'s REST-confirm path (dcm's `serialize_reactions` never returns a per-viewer `reacted` flag; both call sites were wholesale-replacing and silently wiping known reacted state); extended the contract-conformance test with the WO's new BLOCKED-version-pin rule plus a required negative-case test; rewrote `docs/messaging-deviations.md` (rows 38/51/53/56-58 → OK, row 52 → DEV, rows 27/42 stay BLOCKED with the new version pin, substance untouched). **WO-end `ui_reviewer`** (independent, re-derived rows 27/42 against dcm's actual committed `HEAD`, not the WO text or the doc's own claims, per this WO's own corrective rule): no findings — dcm's committed state is still 2.37.0 (a naive check of dcm's working tree showed 2.38.0, but that's uncommitted `MSG-2d` WIP from a concurrent session, not the published state); rows 27/42 confirmed genuinely still BLOCKED at the real committed version; all seven newly-delivered rows spot-checked against dcm's committed serializers/services directly, all match. Flagged for awareness, not actionable now: dcm's uncommitted `MSG-2d` WIP (external_key, thread-receipt read path) will very likely unblock rows 27/42 once committed and published — a future `MSG-3d` candidate. Published: **ucm 2.18.0** (npm, CI-gated on push to `main`, `170ce1e`). No CHANGELOG.md in this repo (same as MSG-3/MSG-3b/NOTIF-12/NOTIF-13) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
35
+ | MSG-3d | The last two blocked rows: unread-reply marker and managed-conversation identity | Deliver checklist rows **27** (unread-reply marker on the thread toggle, from `reply_count`/`last_reply_at` plus the REST-only `thread_last_read_at`) and **42** (surface `external_key` so a host can distinguish managed conversations — ucm must NOT know that `event_all`/`event_team` mean anything, that is jg vocabulary). Deviation doc reaches **zero** BLOCKED entries. | 2026-08-01 | in-review | 6708ec8 | Envelope: [work-orders/MSG-3d.md](work-orders/MSG-3d.md); canonical row in dcm's register. Extends MSG-3c (2.18.0). Unblocked by dcm 2.38.0 (MSG-2d), verified in `serializers.py` before authoring rather than assumed. **Carries a named trap:** `thread_last_read_at` is viewer-specific and REST-only while `reply_count`/`last_reply_at` ride `serialize_message` and therefore the `message`/`message_edited` frames — so every such frame carries the projection *without* the receipt. That is the **fourth** field of the exact shape whose merge behaviour caused three live bugs in MSG-3c; the field-wise-merge obligation is now in design §Realtime and has a mandatory test here. Second named risk: jg vocabulary leaking into a consumer-agnostic package via row 42, with its own assertion. **Tier 2** (raised from Tier 1 on review — the change lands in the frame-merge code where MSG-3c found three shipped bugs; size does not lower review sensitivity), independent `reviewer` + `ui_reviewer` at WO end. Review also surfaced a plan gap now closed in the envelope: a new reply arrives as its own `message` frame and does NOT update the root's counters, so the root must be incremented client-side or the marker only appears after a refetch (jg does this live). **Closes the post-audit remediation** — after this every MSG-3b checklist row is implemented or a recorded deliberate deviation, and MSG-5 is unblocked. **Codex out of credits again** (fourth consecutive WO in this workstream) — implemented directly, independent review mandatory and run. Fixed a real bug while implementing: `markReplyThreadRead` fired the REST read call and discarded the response entirely, so the receipt was never actually cached client-side before this WO. Own review caught a TDZ crash (a `useCallback` dependency array referencing `patchMessage` before its declaration — dependency arrays evaluate eagerly, unlike the deferred callback body) before the independent reviewer ever saw it. Independent `reviewer`: R1 (P2) — the live-root-update test only proved the marker's visibility, which can't distinguish "still read" from "receipt silently wiped"; added a dedicated assertion on the receipt's own value across the bump, the WO's named highest-risk path. R2 (P2) — the marker's `aria-label` sat on MUI's `Badge` wrapper, which spreads unrecognized props onto a non-interactive `span` a screen reader wouldn't focus; moved onto the inner `Button`. R3 (P3) — a dead client-shaped `reply_to` fallback had crept into the new root-bump code despite the WO's explicit warning against it; removed. R4 (P3) — documented in code that the deliberate absence of self-send-marker suppression relies on dcm excluding the sender from live WS fan-out, an unstated cross-repo invariant. Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass and the version-bump/publish step are still outstanding. |
package/dev/entries.jsx CHANGED
@@ -61,15 +61,29 @@ function RealtimeAdapterEntry() {
61
61
  const messagingHarnessApi = {
62
62
  listConversations: async ({ cursor } = {}) => cursor
63
63
  ? ({ results: [{ id: 13, title: 'Archived planning', last_message_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
64
- : ({ results: [{ id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } }], next_cursor: 'signed-next-page' }),
64
+ : ({ results: [
65
+ { id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } },
66
+ // Row 42 (MSG-3d): two managed conversations, distinguishable only via
67
+ // external_key — ucm renders whatever ConversationListEntry's
68
+ // resolveManagedLabel resolver below returns, never the raw value.
69
+ { id: 30, kind: 'managed', external_key: 'all', last_message_at: '2026-07-29T09:00:00Z' },
70
+ { id: 31, kind: 'managed', external_key: 'team', last_message_at: '2026-07-28T09:00:00Z' },
71
+ ], next_cursor: 'signed-next-page' }),
65
72
  // GET conversations/{id}/messages/ returns roots only (design §REST); replies
66
73
  // come from GET messages/{root_id}/thread/ (listThread below), never mixed in here.
74
+ // reply_count with no thread_last_read_at (MSG-3d row 27): the unread-reply
75
+ // marker on the thread toggle should render visibly by default here.
67
76
  listMessages: async (conversationId, { cursor } = {}) => cursor
68
77
  ? ({ results: [{ id: 42, conversation_id: conversationId, body: 'An older message', created_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
69
- : ({ results: [{ id: 44, conversation_id: conversationId, body: 'Initial message', created_at: '2026-07-31T09:00:00Z', sender: { display_name: 'Alex' }, reply_count: 1 }], next_cursor: 'signed-older-page' }),
78
+ : ({ results: [{ id: 44, conversation_id: conversationId, body: 'Initial message', created_at: '2026-07-31T09:00:00Z', sender: { display_name: 'Alex' }, reply_count: 1, last_reply_at: '2026-07-31T09:05:00Z', thread_last_read_at: null }], next_cursor: 'signed-older-page' }),
70
79
  listThread: async (rootId) => ({ results: [{ id: 45, reply_to: rootId, body: 'A reply', created_at: '2026-07-31T09:05:00Z', sender: { display_name: 'Sam' } }], next_cursor: null }),
71
80
  getReadStatus: async () => ({ all_read: false, delivered_count: 2 }),
72
81
  getUnreadCount: async () => ({ unread_count: 1, by_conversation: { 12: 1 } }),
82
+ markConversationRead: async () => ({}),
83
+ // The response's last_read_at (MSG-3d) maps onto the root's own
84
+ // thread_last_read_at cache field — clicking "Show replies" in this harness
85
+ // should make the unread-reply marker disappear.
86
+ markThreadRead: async () => ({ last_read_at: new Date().toISOString() }),
73
87
  archiveConversation: async (id, archived) => ({ id, archived }),
74
88
  patchConversationPreferences: async (id, patch) => ({ id, ...patch }),
75
89
  createGroupConversation: async (payload) => ({ id: 20, title: payload.title || 'Opened group', kind: 'group' }),
@@ -115,6 +129,7 @@ function ConversationListEntry() {
115
129
  groupLaunchers={[{ id: 'volunteers', label: 'Open volunteers group', payload: { title: 'Volunteers', participant_ids: [1, 2] } }]}
116
130
  broadcastLauncher={{ label: 'Open announcements', payload: { scope: { kind: 'global' } } }}
117
131
  onOpen={(conversation) => setOpened(conversation)}
132
+ resolveManagedLabel={(conversation) => (conversation.external_key === 'all' ? 'Everyone' : conversation.external_key === 'team' ? 'My team' : null)}
118
133
  />
119
134
  {opened && <Typography sx={{ mt: 2 }}>Opened: {opened.title}</Typography>}
120
135
  </MessagingProvider>
@@ -31,6 +31,7 @@ export const messagingTranslations = {
31
31
  'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
32
32
  'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
33
33
  'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
34
+ 'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
34
35
  'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
36
  'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
37
  'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
@@ -5,7 +5,19 @@ import { Badge, Box, Button, CircularProgress, IconButton, List, ListItemButton,
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { useMessaging } from './MessagingProvider';
7
7
  import { ConversationLaunchers } from './ConversationLaunchers';
8
- function titleOf(conversation, t) { var _a, _b; return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED'); }
8
+ // external_key (dcm 2.38.0+) is the consuming app's own identity for a
9
+ // managed conversation — ucm must not know what any particular app's values
10
+ // mean, so a label for it is host-supplied only; resolveManagedLabel receives
11
+ // the raw conversation (including external_key) and owns all interpretation.
12
+ function titleOf(conversation, t, resolveManagedLabel) {
13
+ var _a, _b;
14
+ if (conversation.kind === 'managed' && resolveManagedLabel) {
15
+ const label = resolveManagedLabel(conversation);
16
+ if (label)
17
+ return label;
18
+ }
19
+ return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED');
20
+ }
9
21
  function ordered(conversations) {
10
22
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
11
23
  }
@@ -28,7 +40,7 @@ function KindIcon({ conversation }) {
28
40
  return _jsx(Person, { fontSize: "small" });
29
41
  }
30
42
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
31
- export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
43
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
32
44
  const { t, i18n } = useTranslation();
33
45
  const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
34
46
  const [menuConversation, setMenuConversation] = useState(null);
@@ -77,6 +89,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
77
89
  var _a;
78
90
  const unread = unreadFor(conversation);
79
91
  const archived = Boolean(conversation.archived || conversation.archived_at);
80
- return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation); }, sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.excerpt) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
92
+ return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation); }, sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t, resolveManagedLabel) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.excerpt) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
81
93
  }) })), cache.cursors.conversations && _jsx(Button, { onClick: loadMore, disabled: loadingMore, children: loadingMore ? _jsx(CircularProgress, { size: 18 }) : t('MessagingList.LOAD_MORE') }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuConversation), onClose: closeMenu, children: [_jsxs(MenuItem, { onClick: toggleMute, children: [_jsx(NotificationsOff, { fontSize: "small", sx: { mr: 1 } }), (menuConversation === null || menuConversation === void 0 ? void 0 : menuConversation.muted) ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')] }), _jsxs(MenuItem, { onClick: archive, children: [_jsx(Archive, { fontSize: "small", sx: { mr: 1 } }), t('MessagingList.ARCHIVE')] })] })] }));
82
94
  }
@@ -99,7 +99,32 @@ export function applyFrame(state, frame, activeConversationId = null) {
99
99
  const message = Object.assign(Object.assign({}, payload), { conversation_id: conversationId });
100
100
  const requestId = clientRequestIdOf(message);
101
101
  const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
102
- const messages = reconcileMessage(state.messages, message);
102
+ let messages = reconcileMessage(state.messages, message);
103
+ // reply_count/last_reply_at are viewer-independent facts (serialize_message
104
+ // always includes them, live-recomputed when not annotated) — bump the
105
+ // cached root unconditionally, regardless of who sent the reply. Spread
106
+ // the existing root first and only override these two fields, never
107
+ // rebuild the object from frame data, so thread_last_read_at (REST-only,
108
+ // never present on this frame) is never wiped — the merge-trap discipline
109
+ // MSG-3c's fixes established for exactly this failure shape.
110
+ //
111
+ // No self-send suppression here: a viewer never receives a "message"
112
+ // frame for their own reply in the first place — dcm's
113
+ // resolve_live_recipients(sender=actor) excludes the sender from the
114
+ // live WS fan-out (django_core_micha/messaging/services.py), so this
115
+ // branch structurally cannot fire for the viewer's own send. That is a
116
+ // dcm-side guarantee this code relies on, not something ucm re-derives —
117
+ // if dcm's recipient exclusion ever changes, this would need revisiting.
118
+ //
119
+ // reply_to_id only, never a client-shaped reply_to fallback — a live
120
+ // "message" frame is always server-shaped (serialize_message), so a
121
+ // reply_to fallback here would be dead-code-guessing at a payload shape
122
+ // no server ever sends, exactly the kind of unverified compatibility
123
+ // guess this workstream has twice shipped as a real bug.
124
+ const rootId = message.reply_to_id;
125
+ const root = rootId != null ? messages[rootId] : null;
126
+ if (root)
127
+ messages = Object.assign(Object.assign({}, messages), { [rootId]: Object.assign(Object.assign({}, root), { reply_count: (root.reply_count || 0) + 1, last_reply_at: message.created_at || root.last_reply_at }) });
103
128
  if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage)
104
129
  return Object.assign(Object.assign({}, state), { messages });
105
130
  const unread = state.unread || EMPTY_CACHE.unread;
@@ -228,7 +253,6 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
228
253
  dispatch({ type: 'conversationRead', conversationId });
229
254
  return result;
230
255
  }, [api]);
231
- const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
232
256
  const openGroupConversation = useCallback(async (payload) => {
233
257
  const conversation = await api.createGroupConversation(payload);
234
258
  return updateConversation(conversation);
@@ -282,6 +306,17 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
282
306
  }
283
307
  }, [api, user]);
284
308
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
309
+ const markReplyThreadRead = useCallback(async (rootId, readAt) => {
310
+ const result = await api.markThreadRead(rootId, readAt);
311
+ // The mark-read response's `last_read_at` maps onto the cached root
312
+ // message's `thread_last_read_at` field — different key names, same
313
+ // value; this is the only place the client ever learns this viewer's own
314
+ // thread receipt, since it's REST-only and never rides a realtime frame.
315
+ const previous = cache.messages[rootId];
316
+ if (previous && (result === null || result === void 0 ? void 0 : result.last_read_at) !== undefined)
317
+ patchMessage(Object.assign(Object.assign({}, previous), { thread_last_read_at: result.last_read_at }));
318
+ return result;
319
+ }, [api, cache.messages, patchMessage]);
285
320
  // Keep this API-backed action deliberately distinct from the cache-merge
286
321
  // helper above. The server remains the authority for author/moderator rights.
287
322
  const editMessage = useCallback(async (messageId, patch) => {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
+ import { Alert, Badge, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
3
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { AuthContext } from '../auth/AuthContext';
@@ -8,6 +8,17 @@ import { useMessaging } from './MessagingProvider';
8
8
  import { ReadTicks } from './ReadTicks';
9
9
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
10
10
  function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
11
+ // last_reply_at/reply_count are viewer-independent (always on serialize_message);
12
+ // thread_last_read_at is viewer-specific and REST-only (never on a frame — see
13
+ // MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
14
+ // at least one reply counts as unread; no replies means no marker at all.
15
+ function hasUnreadReplies(message) {
16
+ if (!message.reply_count)
17
+ return false;
18
+ if (message.thread_last_read_at == null)
19
+ return true;
20
+ return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(message.thread_last_read_at);
21
+ }
11
22
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
12
23
  // and no durable server row yet — mounting ReadTicks against it would fire a
13
24
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -105,6 +116,14 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
105
116
  };
106
117
  return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), !roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') }), _jsx(Stack, { spacing: 1, children: roots.map((message) => {
107
118
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
108
- return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
119
+ return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && (() => {
120
+ const unread = hasUnreadReplies(message);
121
+ const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
122
+ // MUI's Badge spreads unrecognized props (incl. aria-label) onto
123
+ // its own non-interactive wrapping span, not the inner control —
124
+ // a screen reader focuses the Button, so the accessible name
125
+ // must live there, not on the purely-visual dot.
126
+ return _jsx(Badge, { color: "error", variant: "dot", invisible: !unread, children: _jsx(Button, { size: "small", "aria-label": unread ? `${label} — ${t('MessagingThread.UNREAD_REPLIES')}` : undefined, onClick: () => toggleReplies(message), children: label }) });
127
+ })(), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
109
128
  }) })] })] }));
110
129
  }
@@ -1,4 +1,4 @@
1
- # Messaging parity outcomes (MSG-3b, extended by MSG-3c)
1
+ # Messaging parity outcomes (MSG-3b, extended by MSG-3c and MSG-3d)
2
2
 
3
3
  This is the authoritative parity record for the ucm messaging surfaces. It
4
4
  replaces the former file-level “final parity confirmation,” which was not
@@ -55,7 +55,7 @@ now fails the build if a `BLOCKED` line omits one.
55
55
  24. **OK** — Replies show a sender and short quoted snippet.
56
56
  25. **OK** — Clicking a quote scrolls its source message into view.
57
57
  26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
58
- 27. **BLOCKED** (blocked as of dcm 2.37.0) — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
58
+ 27. **OK** (delivered in MSG-3d, was BLOCKED as of dcm 2.37.0) — dcm 2.38.0's `serialize_message` gains viewer-independent `reply_count`/`last_reply_at`, and `thread_last_read_at` is attached REST-only from `MessageThreadReceipt`. `Thread` shows an unread-reply marker on the thread toggle when `last_reply_at > thread_last_read_at`, treats a `null` receipt with replies as unread, and shows no marker (and no toggle) when there are no replies. Opening a thread updates the receipt from the REST response, not a frame (the field is never present on one, by design). An incoming reply's `message` frame also live-advances the cached root's `reply_count`/`last_reply_at`, merged field-wise so a previously-fetched `thread_last_read_at` is never wiped — the same merge-trap discipline MSG-3c's three fixes established, now with its own mandatory regression test.
59
59
  28. **OK** — Reply threads load lazily on expand and collapse locally.
60
60
  29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
61
61
 
@@ -76,7 +76,7 @@ now fails the build if a `BLOCKED` line omits one.
76
76
  39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
77
77
  40. **OK** — The existing MUI `selected={conversation.id === activeConversationId}` wiring produces the `Mui-selected` active-row highlight; it was verified and needs no redundant styling.
78
78
  41. **OK** — Unread badges and bold unread titles are present.
79
- 42. **BLOCKED** (blocked as of dcm 2.37.0) — dcm stores the managed all/team distinction in `external_key`, but `serialize_conversation_core` still omits it from every conversation payload. The UI must not guess from titles or other unrelated fields. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
79
+ 42. **OK** (delivered in MSG-3d, was BLOCKED as of dcm 2.37.0) — dcm 2.38.0's `serialize_conversation_core` includes `external_key`. ucm does not interpret it — jg's `event_all`/`event_team` vocabulary (or any other app's) stays entirely host-side. `ConversationList` accepts an optional `resolveManagedLabel(conversation)` host resolver, called only for `kind: 'managed'` conversations, falling back to the existing title behavior when unset; no app-specific string appears anywhere in `src/` (asserted directly by test).
80
80
  43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
81
81
  44. **OK** — Mute and archive actions are present.
82
82
 
@@ -109,10 +109,10 @@ now fails the build if a `BLOCKED` line omits one.
109
109
 
110
110
  ## Required backend follow-up
111
111
 
112
- Rows 27 and 42 require a future dcm work order (`MSG-2d`): expose readable thread receipt state, and
113
- serialize `external_key` where managed all/team labeling is required. Until then, these are explicit
114
- BLOCKED outcomes, each pinned to the dcm version they were verified blocked against, rather than
115
- simulated client behavior. MSG-2c (dcm 2.37.0) already delivered what MSG-3b's own audit had recorded
116
- as blocking rows 38, 51–53 and 56–58 — MSG-3c re-verified each against the live dependency and
117
- delivered them; see those rows above for what was actually built (in three cases, fixing a real
118
- client-side bug found only once real server data was checked against, not just unblocked).
112
+ None outstanding. This checklist has **zero `BLOCKED` entries** as of MSG-3d. MSG-2c (dcm 2.37.0)
113
+ delivered what MSG-3b's own audit had recorded as blocking rows 38, 51–53 and 56–58 — MSG-3c
114
+ re-verified each against the live dependency and delivered them. MSG-2d (dcm 2.38.0) delivered what
115
+ remained blocking rows 27 and 42 — MSG-3d re-verified and delivered them the same way. Every row of
116
+ the original MSG-3b checklist is now either implemented or a recorded deliberate deviation; see the
117
+ rows above for what was actually built, including the client-side bugs found and fixed only once real
118
+ server data was checked against, not assumed from the WO text or the doc's own prior claims.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -31,6 +31,7 @@ export const messagingTranslations = {
31
31
  'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
32
32
  'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
33
33
  'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
34
+ 'MessagingThread.UNREAD_REPLIES': { de: 'Ungelesene Antworten', en: 'Unread replies', fr: 'Réponses non lues' },
34
35
  'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
35
36
  'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
36
37
  'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
@@ -6,7 +6,17 @@ import { useTranslation } from 'react-i18next';
6
6
  import { useMessaging } from './MessagingProvider';
7
7
  import { ConversationLaunchers } from './ConversationLaunchers';
8
8
 
9
- function titleOf(conversation, t) { return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED'); }
9
+ // external_key (dcm 2.38.0+) is the consuming app's own identity for a
10
+ // managed conversation — ucm must not know what any particular app's values
11
+ // mean, so a label for it is host-supplied only; resolveManagedLabel receives
12
+ // the raw conversation (including external_key) and owns all interpretation.
13
+ function titleOf(conversation, t, resolveManagedLabel) {
14
+ if (conversation.kind === 'managed' && resolveManagedLabel) {
15
+ const label = resolveManagedLabel(conversation);
16
+ if (label) return label;
17
+ }
18
+ return conversation.title || conversation.other_user?.display_name || conversation.other_user?.username || t('MessagingList.UNTITLED');
19
+ }
10
20
  function ordered(conversations) {
11
21
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
12
22
  }
@@ -27,7 +37,7 @@ function KindIcon({ conversation }) {
27
37
  }
28
38
 
29
39
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
30
- export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
40
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false, resolveManagedLabel }) {
31
41
  const { t, i18n } = useTranslation();
32
42
  const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
33
43
  const [menuConversation, setMenuConversation] = useState(null);
@@ -68,7 +78,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
68
78
  <Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
69
79
  {archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
70
80
  </Badge>
71
- <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.excerpt || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
81
+ <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t, resolveManagedLabel)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.excerpt || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
72
82
  <IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
73
83
  </ListItemButton>;
74
84
  })}
@@ -127,7 +127,31 @@ export function applyFrame(state, frame, activeConversationId = null) {
127
127
  const message = { ...payload, conversation_id: conversationId };
128
128
  const requestId = clientRequestIdOf(message);
129
129
  const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
130
- const messages = reconcileMessage(state.messages, message);
130
+ let messages = reconcileMessage(state.messages, message);
131
+ // reply_count/last_reply_at are viewer-independent facts (serialize_message
132
+ // always includes them, live-recomputed when not annotated) — bump the
133
+ // cached root unconditionally, regardless of who sent the reply. Spread
134
+ // the existing root first and only override these two fields, never
135
+ // rebuild the object from frame data, so thread_last_read_at (REST-only,
136
+ // never present on this frame) is never wiped — the merge-trap discipline
137
+ // MSG-3c's fixes established for exactly this failure shape.
138
+ //
139
+ // No self-send suppression here: a viewer never receives a "message"
140
+ // frame for their own reply in the first place — dcm's
141
+ // resolve_live_recipients(sender=actor) excludes the sender from the
142
+ // live WS fan-out (django_core_micha/messaging/services.py), so this
143
+ // branch structurally cannot fire for the viewer's own send. That is a
144
+ // dcm-side guarantee this code relies on, not something ucm re-derives —
145
+ // if dcm's recipient exclusion ever changes, this would need revisiting.
146
+ //
147
+ // reply_to_id only, never a client-shaped reply_to fallback — a live
148
+ // "message" frame is always server-shaped (serialize_message), so a
149
+ // reply_to fallback here would be dead-code-guessing at a payload shape
150
+ // no server ever sends, exactly the kind of unverified compatibility
151
+ // guess this workstream has twice shipped as a real bug.
152
+ const rootId = message.reply_to_id;
153
+ const root = rootId != null ? messages[rootId] : null;
154
+ if (root) messages = { ...messages, [rootId]: { ...root, reply_count: (root.reply_count || 0) + 1, last_reply_at: message.created_at || root.last_reply_at } };
131
155
  if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage) return { ...state, messages };
132
156
  const unread = state.unread || EMPTY_CACHE.unread;
133
157
  const previousCount = unread.by_conversation?.[conversationId] || 0;
@@ -259,7 +283,6 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
259
283
  dispatch({ type: 'conversationRead', conversationId });
260
284
  return result;
261
285
  }, [api]);
262
- const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
263
286
  const openGroupConversation = useCallback(async (payload) => {
264
287
  const conversation = await api.createGroupConversation(payload);
265
288
  return updateConversation(conversation);
@@ -318,6 +341,16 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
318
341
  }
319
342
  }, [api, user]);
320
343
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
344
+ const markReplyThreadRead = useCallback(async (rootId, readAt) => {
345
+ const result = await api.markThreadRead(rootId, readAt);
346
+ // The mark-read response's `last_read_at` maps onto the cached root
347
+ // message's `thread_last_read_at` field — different key names, same
348
+ // value; this is the only place the client ever learns this viewer's own
349
+ // thread receipt, since it's REST-only and never rides a realtime frame.
350
+ const previous = cache.messages[rootId];
351
+ if (previous && result?.last_read_at !== undefined) patchMessage({ ...previous, thread_last_read_at: result.last_read_at });
352
+ return result;
353
+ }, [api, cache.messages, patchMessage]);
321
354
  // Keep this API-backed action deliberately distinct from the cache-merge
322
355
  // helper above. The server remains the authority for author/moderator rights.
323
356
  const editMessage = useCallback(async (messageId, patch) => {
@@ -1,4 +1,4 @@
1
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
1
+ import { Alert, Badge, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
2
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
 
@@ -9,6 +9,15 @@ import { ReadTicks } from './ReadTicks';
9
9
 
10
10
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
11
11
  function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
12
+ // last_reply_at/reply_count are viewer-independent (always on serialize_message);
13
+ // thread_last_read_at is viewer-specific and REST-only (never on a frame — see
14
+ // MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
15
+ // at least one reply counts as unread; no replies means no marker at all.
16
+ function hasUnreadReplies(message) {
17
+ if (!message.reply_count) return false;
18
+ if (message.thread_last_read_at == null) return true;
19
+ return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(message.thread_last_read_at);
20
+ }
12
21
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
13
22
  // and no durable server row yet — mounting ReadTicks against it would fire a
14
23
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -92,7 +101,15 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
92
101
  <Stack spacing={1}>{roots.map((message) => {
93
102
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
94
103
  return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
95
- {(message.reply_count || replies.length) > 0 && <Button size="small" onClick={() => toggleReplies(message)}>{openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length })}</Button>}
104
+ {(message.reply_count || replies.length) > 0 && (() => {
105
+ const unread = hasUnreadReplies(message);
106
+ const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
107
+ // MUI's Badge spreads unrecognized props (incl. aria-label) onto
108
+ // its own non-interactive wrapping span, not the inner control —
109
+ // a screen reader focuses the Button, so the accessible name
110
+ // must live there, not on the purely-visual dot.
111
+ return <Badge color="error" variant="dot" invisible={!unread}><Button size="small" aria-label={unread ? `${label} — ${t('MessagingThread.UNREAD_REPLIES')}` : undefined} onClick={() => toggleReplies(message)}>{label}</Button></Badge>;
112
+ })()}
96
113
  {openThreads[message.id] && <Stack spacing={0.75} sx={{ pl: 3, borderLeft: 2, borderColor: 'divider' }}>{replies.map((item) => <MessageBubble key={item.id} message={item} replyTo={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
97
114
  <Divider /></Stack>;
98
115
  })}</Stack>
@@ -0,0 +1,170 @@
1
+ // @vitest-environment jsdom
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import React from 'react';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
7
+
8
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count !== undefined ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
9
+
10
+ const realtimeSubscribers = new Map();
11
+ vi.mock('../src/notifications/realtime', () => ({
12
+ useRealtime: () => ({
13
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
14
+ onReconnect: () => () => {},
15
+ }),
16
+ }));
17
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
18
+
19
+ import { ConversationList } from '../src/messaging/ConversationList';
20
+ import { Thread } from '../src/messaging/Thread';
21
+ import { applyFrame, MessagingProvider } from '../src/messaging/MessagingProvider';
22
+
23
+ // Server-shaped serialize_message output (django_core_micha/messaging/
24
+ // serializers.py:80-95): reply_count/last_reply_at always present;
25
+ // thread_last_read_at is REST-only and never on a frame — copied from the
26
+ // real _message_response/_message_page_response wiring in views.py, not
27
+ // hand-invented.
28
+ function serverRoot(overrides = {}) {
29
+ return { id: 'root-1', conversation_id: 'conv-1', body: 'Root', sender_id: 'user-1', kind: 'chat', reply_count: 1, last_reply_at: '2026-08-01T09:00:00Z', thread_last_read_at: null, ...overrides };
30
+ }
31
+ function serverReply(overrides = {}) {
32
+ return { id: 'reply-1', conversation_id: 'conv-1', body: 'Reply', sender_id: 'user-2', kind: 'chat', reply_to_id: 'root-1', reply_count: 0, last_reply_at: null, created_at: '2026-08-01T10:00:00Z', ...overrides };
33
+ }
34
+ function baseApi(overrides = {}) {
35
+ return {
36
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
37
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
38
+ markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn(),
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
44
+
45
+ describe('the merge trap — thread_last_read_at must survive frames that never carry it', () => {
46
+ function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
47
+
48
+ it('a server-shaped message frame does not wipe a previously-fetched thread_last_read_at on an unrelated root', () => {
49
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ thread_last_read_at: '2026-08-01T08:00:00Z' }) } };
50
+ // A brand-new, unrelated root message frame — server-shaped, no
51
+ // thread_last_read_at key, exactly as serialize_message emits it.
52
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message', event_id: 'evt-1', conversation_id: 'conv-1', message: serverRoot({ id: 'root-2', thread_last_read_at: undefined, reply_to_id: undefined }) }, 'conv-1');
53
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T08:00:00Z');
54
+ });
55
+
56
+ it('a server-shaped message_edited frame does not wipe the edited root\'s own thread_last_read_at', () => {
57
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ thread_last_read_at: '2026-08-01T08:00:00Z', body: 'Before' }) } };
58
+ const edited = serverRoot({ body: 'After', thread_last_read_at: undefined });
59
+ delete edited.thread_last_read_at;
60
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message_edited', event_id: 'evt-2', conversation_id: 'conv-1', message_id: 'root-1', message: edited }, null);
61
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T08:00:00Z');
62
+ expect(next.messages['root-1'].body).toBe('After');
63
+ });
64
+
65
+ it('a reply frame bumping a cached root\'s reply_count/last_reply_at does not wipe that root\'s thread_last_read_at — the WO\'s highest-risk path', () => {
66
+ // The receipt is deliberately NEWER than the incoming reply, so
67
+ // hasUnreadReplies-style logic would already read "read" before this
68
+ // frame — if the bump wiped thread_last_read_at to null/undefined instead
69
+ // of preserving it, that derived read-state would flip, which this
70
+ // assertion catches directly (unlike a marker-visibility assertion,
71
+ // which can't distinguish "correctly still read" from "field wiped to
72
+ // null" — both render no marker).
73
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ reply_count: 1, last_reply_at: '2026-08-01T08:00:00Z', thread_last_read_at: '2026-08-01T09:00:00Z' }) } };
74
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message', event_id: 'evt-4', conversation_id: 'conv-1', message: serverReply({ id: 'reply-2', created_at: '2026-08-01T10:00:00Z' }) }, 'conv-1');
75
+ expect(next.messages['root-1'].reply_count).toBe(2);
76
+ expect(next.messages['root-1'].last_reply_at).toBe('2026-08-01T10:00:00Z');
77
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T09:00:00Z');
78
+ });
79
+ });
80
+
81
+ describe('unread-reply marker logic', () => {
82
+ it('shows when last_reply_at is newer than the receipt', async () => {
83
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ last_reply_at: '2026-08-01T10:00:00Z', thread_last_read_at: '2026-08-01T09:00:00Z' })] }) });
84
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
85
+ await screen.findByText('Root');
86
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
87
+ });
88
+
89
+ it('hides when the receipt is equal to or newer than last_reply_at', async () => {
90
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ last_reply_at: '2026-08-01T09:00:00Z', thread_last_read_at: '2026-08-01T10:00:00Z' })] }) });
91
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
92
+ await screen.findByText('Root');
93
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
94
+ });
95
+
96
+ it('shows when the receipt is null and reply_count > 0', async () => {
97
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ thread_last_read_at: null, reply_count: 2, last_reply_at: '2026-08-01T09:00:00Z' })] }) });
98
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
99
+ await screen.findByText('Root');
100
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
101
+ });
102
+
103
+ it('renders no toggle and no marker at all when reply_count is 0', async () => {
104
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ reply_count: 0, last_reply_at: null, thread_last_read_at: null })] }) });
105
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
106
+ await screen.findByText('Root');
107
+ expect(screen.queryByText(/MessagingThread\.SHOW_REPLIES/)).toBeNull();
108
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe('opening a thread marks it read from the REST response', () => {
113
+ it('issues the REST read call and updates the cached receipt from last_read_at', async () => {
114
+ const api = baseApi({
115
+ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ thread_last_read_at: null, reply_count: 1, last_reply_at: '2026-08-01T09:00:00Z' })] }),
116
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
117
+ markThreadRead: vi.fn().mockResolvedValue({ last_read_at: '2026-08-01T12:00:00Z' }),
118
+ });
119
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
120
+ await screen.findByText('Root');
121
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
122
+ fireEvent.click(screen.getByText(/MessagingThread\.SHOW_REPLIES/));
123
+ await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith('root-1', undefined));
124
+ await waitFor(() => expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull());
125
+ });
126
+ });
127
+
128
+ describe('live root update on an incoming reply', () => {
129
+ it('raises reply_count/last_reply_at on the cached root and the marker appears without a refetch when the sender is not the viewer', async () => {
130
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ reply_count: 1, last_reply_at: '2026-08-01T08:00:00Z', thread_last_read_at: '2026-08-01T08:30:00Z' })] }) });
131
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
132
+ await screen.findByText(/MessagingThread\.SHOW_REPLIES:1/);
133
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
134
+ // A new reply from someone else — server-shaped message frame with
135
+ // reply_to_id naming the cached root.
136
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'evt-3', conversation_id: 'conv-1', message: serverReply() });
137
+ await waitFor(() => expect(screen.getByText(/MessagingThread\.SHOW_REPLIES:2/)).toBeTruthy());
138
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
139
+ });
140
+ });
141
+
142
+ describe('managed-conversation identity (row 42)', () => {
143
+ it('renders two managed conversations with different external_key as distinguishable via a host resolver', async () => {
144
+ const api = baseApi({
145
+ listConversations: vi.fn().mockResolvedValue({ results: [
146
+ { id: 'c1', kind: 'managed', external_key: 'all' },
147
+ { id: 'c2', kind: 'managed', external_key: 'team' },
148
+ ] }),
149
+ });
150
+ render(<MessagingProvider api={api}><ConversationList resolveManagedLabel={(conversation) => conversation.external_key === 'all' ? 'Everyone' : 'My team'} /></MessagingProvider>);
151
+ await screen.findByText('Everyone');
152
+ expect(screen.getByText('My team')).toBeTruthy();
153
+ });
154
+
155
+ it('falls back to the existing title behavior when no resolver is supplied', async () => {
156
+ const api = baseApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'managed', external_key: 'all', title: 'Fallback title' }] }) });
157
+ render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
158
+ await screen.findByText('Fallback title');
159
+ });
160
+
161
+ it('no jg-specific vocabulary (event_all, event_team) appears anywhere in src/', () => {
162
+ const root = path.resolve(import.meta.dirname, '..', 'src');
163
+ const files = fs.readdirSync(root, { recursive: true }).filter((file) => typeof file === 'string' && /\.(jsx?|tsx?)$/.test(file));
164
+ const offenders = files.filter((file) => {
165
+ const contents = fs.readFileSync(path.join(root, file), 'utf8');
166
+ return /event_all|event_team/.test(contents);
167
+ });
168
+ expect(offenders).toEqual([]);
169
+ });
170
+ });
@@ -45,7 +45,10 @@ describe('messaging unread lifecycle', () => {
45
45
  it('marks a reply thread read when it expands', async () => {
46
46
  const api = makeApi({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 40, conversation_id: 12, body: 'Root', reply_count: 1 }] }) });
47
47
  render(<MessagingProvider api={api} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>);
48
- fireEvent.click(await screen.findByRole('button', { name: 'MessagingThread.SHOW_REPLIES' }));
48
+ // No thread_last_read_at in this fixture — the unread-reply marker
49
+ // (MSG-3d) correctly treats a null receipt with replies as unread, which
50
+ // changes the button's accessible name to include the marker text.
51
+ fireEvent.click(await screen.findByRole('button', { name: /MessagingThread\.SHOW_REPLIES/ }));
49
52
  await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith(40, undefined));
50
53
  });
51
54
 
@@ -0,0 +1,268 @@
1
+ # MSG-3d — the last two blocked rows: unread-reply marker and managed-conversation identity
2
+
3
+ Status: planned · Tier 2 · Target repo: `ui-core-micha` (main)
4
+ **Binding spec:** `django-core-micha/docs/design/messaging-platform.md` — §"Thread reply state and
5
+ managed-conversation identity" and §Realtime's consumer obligation. Extends the landed MSG-3c
6
+ (published ucm 2.18.0), same convention as `MSG-2b` / `MSG-3b` / `MSG-3c`.
7
+
8
+ ---
9
+
10
+ ## Part A — Envelope (Expertenchat, 2026-08-01)
11
+
12
+ ### Goal
13
+
14
+ Deliver checklist rows **27** and **42** — the last two entries still marked BLOCKED in
15
+ `docs/messaging-deviations.md`. dcm 2.38.0 (MSG-2d) shipped the contract they were waiting for, on the
16
+ same day MSG-3c closed. After this WO the deviation doc has **zero** BLOCKED entries.
17
+
18
+ ### Verified deliverable against dcm 2.38.0
19
+
20
+ Checked in `django-core-micha` before this WO was written (`serializers.py`):
21
+
22
+ | Row | Was blocked on | Present in 2.38.0 |
23
+ |---|---|---|
24
+ | 27 | no readable thread reply state | `reply_count` + `last_reply_at` on `serialize_message` (viewer-independent), `thread_last_read_at` added REST-only |
25
+ | 42 | `external_key` not serialized | `external_key` on `serialize_conversation_core` |
26
+
27
+ ### Expected outcome
28
+
29
+ **Row 27 — unread-reply marker on the thread toggle.** Render the marker when
30
+ `last_reply_at > thread_last_read_at`; a `null` receipt with at least one reply counts as unread; no
31
+ replies means no toggle and no marker. `reply_count` gives the toggle its count without expanding the
32
+ thread. Opening a thread marks it read and updates the receipt **from the REST response** to
33
+ `POST messages/{root_id}/thread/read/` — not from a frame, which cannot carry it.
34
+
35
+ **Live root update on an incoming reply.** A new reply arrives as a `message` frame carrying *the
36
+ reply's* projection — its own `reply_count` is 0 and `last_reply_at` null. Nothing in the frame updates
37
+ the **root's** counters, so without explicit handling the marker only appears after the next refetch.
38
+ jg updates the root live (`Thread.jsx:1488-1537`: increments `reply_count` and marks the thread unread
39
+ when the sender is someone else), so parity requires the same here: on a `message` frame whose
40
+ `reply_to_id` names a cached root, increment that root's `reply_count` and advance its `last_reply_at`.
41
+ This was a gap in the first draft of this envelope, surfaced by review — do not leave it to discovery.
42
+
43
+ **Row 42 — distinguish managed conversations.** Surface `external_key` so a host can tell one managed
44
+ conversation from another. **ucm must not know what the values mean.** `event_all` and `event_team` are
45
+ jg's vocabulary, not the platform's; baking them in would put app-specific code in a consumer-agnostic
46
+ package and break the first non-jg adopter. Expose the value and let the host supply label and icon,
47
+ mirroring the existing `groupLaunchers` host-supplied pattern.
48
+
49
+ **Deviation doc.** Rows 27 and 42 move from BLOCKED to delivered. The doc then contains no BLOCKED
50
+ entries at all — confirm MSG-3c's version-pin conformance check still passes on an empty set rather
51
+ than erroring or vacuously "passing" for the wrong reason.
52
+
53
+ ### The trap this WO walks into — read before writing any merge code
54
+
55
+ `thread_last_read_at` is **viewer-specific and REST-only**. `reply_count` and `last_reply_at` ride
56
+ `serialize_message`, which is embedded verbatim into the `message` and `message_edited` frames. So every
57
+ such frame carries a message projection **with** the reply fields and **without** the receipt.
58
+
59
+ That is precisely the defect shape MSG-3c just fixed three times (`poll_updated`'s `voted_option_ids`,
60
+ the `reaction` frame's `reacted` flags, `toggleReaction`'s REST confirm): a viewer-independent
61
+ projection replacing a cached object and silently wiping viewer-specific state. This WO introduces a
62
+ **fourth field of that shape**, arriving immediately after those fixes. Merge field-wise, preserve
63
+ `thread_last_read_at`, never replace the message object wholesale — the contract obligation is now
64
+ written into design §Realtime. It has a mandatory test below.
65
+
66
+ ### Non-goals / do-not-touch
67
+
68
+ Any dcm change. Any app-specific vocabulary in ucm (see row 42). App-side code, the jg migration
69
+ (MSG-5), search, typing, client→server WS. The component boundaries from MSG-3 — the marker belongs to
70
+ whichever component owns the thread toggle, not to a regrown `Thread`; the ~400 LOC soft trigger and
71
+ one-harness-entry-per-component rule stay in force. Do not re-open rows already delivered or already
72
+ recorded as deliberate deviations.
73
+
74
+ ### Required tests to WRITE
75
+
76
+ - **The merge trap (load-bearing):** a `message` and a `message_edited` frame — **server-shaped, copied
77
+ from real `serialize_message` output**, therefore without `thread_last_read_at` — must leave a
78
+ previously-fetched receipt intact. Assert the cached value after the merge, not just that no error
79
+ was thrown.
80
+ - **Marker logic:** shown when `last_reply_at > thread_last_read_at`; hidden when the receipt is equal
81
+ or newer; shown when the receipt is `null` and `reply_count > 0`; absent entirely when
82
+ `reply_count == 0`.
83
+ - **Read on open:** expanding a thread issues the REST read call and updates the receipt from its
84
+ response.
85
+ - **Live root update:** a server-shaped `message` frame carrying a `reply_to_id` for a cached root
86
+ raises that root's `reply_count` and advances `last_reply_at`, and the marker appears without a
87
+ refetch when the sender is not the viewer.
88
+ - **Row 42:** two managed conversations with different `external_key` values are distinguishable through
89
+ whatever the host supplies, and **no jg-specific string appears anywhere in `src/`** — assert that
90
+ directly, it is the constraint most likely to be quietly violated.
91
+ - **Deviation doc / conformance:** zero BLOCKED entries and the version-pin check still meaningful.
92
+ - Existing ucm suites stay green.
93
+
94
+ **Fixture rule, non-negotiable:** every payload in these tests is copied from actual dcm serializer
95
+ output. Two separate defects in this workstream (`reply_to` vs `reply_to_id`, `last_message.body` vs
96
+ `.excerpt`) survived only because hand-written client-shaped fixtures agreed with the client's mistake.
97
+
98
+ ### Risks
99
+
100
+ - **The merge trap**, above — the single most likely way this WO ships a regression, and the reason its
101
+ first test is mandatory rather than nice-to-have.
102
+ - **jg vocabulary leaking into ucm** on row 42; it has its own assertion.
103
+ - Small WO, low complexity — the risk is complacency, not difficulty.
104
+
105
+ ### Reviews
106
+
107
+ **Tier 2, not Tier 1 — corrected on review.** The row count is small but the change lands in
108
+ `MessagingProvider`'s frame-merge code, which is exactly where MSG-3c's Tier-2 pass found three shipped
109
+ bugs, and this WO's own risk section calls its merge trap the most likely way it ships a regression.
110
+ That is review-sensitive work by AGENTS.md's definition; size does not lower the tier.
111
+
112
+ Independent `reviewer` plus **`ui_reviewer`** at WO end: the marker is a visual
113
+ affordance and the harness exists to look at it. The `ui_reviewer` must also confirm the deviation doc
114
+ reaches zero BLOCKED entries honestly — by delivery, not by deletion.
115
+
116
+ ### Preconditions
117
+
118
+ dcm 2.38.0 published (met — verified in source, not assumed). Approval Gate #1 = operator go on this
119
+ envelope.
120
+
121
+ ### Release
122
+
123
+ One version bump + npm publish at WO end. **This closes the post-audit remediation**: with rows 27 and
124
+ 42 delivered, every row of the MSG-3b checklist is either implemented or a recorded deliberate
125
+ deviation, and MSG-5 becomes unblocked.
126
+
127
+ ### Execution directive
128
+
129
+ Implement through `codex exec` in the background — invoked **directly via Bash** (never the
130
+ `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check` and
131
+ `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from a file;
132
+ fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit. Codex has
133
+ been out of workspace credits recently — verify before assuming the fallback, and if the fallback is
134
+ used, the orchestrator becomes author and the independent review is mandatory, not routine.
135
+
136
+ ### Mini-handover (pastable)
137
+
138
+ Orchestrator: implement `work-orders/MSG-3d.md` in `ui-core-micha` (main). `git pull` first, read the WO
139
+ + `django-core-micha/docs/design/messaging-platform.md` (§"Thread reply state and managed-conversation
140
+ identity", §Realtime's consumer obligation), then follow `orchestrate-codex` (Codex-first, own
141
+ independent review, `ui_reviewer` at WO end, one publish at WO end).
142
+
143
+ ---
144
+
145
+ ## Part B — Implementation map (Orchestrator)
146
+
147
+ **Single chunk** (the envelope's own framing: small WO, low complexity — Tier 2 for review rigor, not
148
+ for decomposition). **Target repo working directory:** `C:\Users\biglmi\Documents\webapps\ui-core-micha`
149
+ (repo root).
150
+
151
+ **Verified against dcm 2.38.0 while scoping (do not re-derive):**
152
+ - `serialize_message` (`django-core-micha/src/django_core_micha/messaging/serializers.py:80-95`) ALWAYS
153
+ includes `reply_count`/`last_reply_at` — annotated-queryset fast path when present
154
+ (`_with_reply_count`, `views.py:39-47`, used by `ConversationMessagesView`/`ThreadView`), a live
155
+ aggregate fallback otherwise (`_reply_stats`, `serializers.py:68-77`). Every `message`/`message_edited`
156
+ frame (built from `serialize_message` via `realtime.py`'s `publish_messaging_event`) therefore always
157
+ carries fresh, viewer-independent reply stats for that specific message.
158
+ - `thread_last_read_at` is added **only** by `_message_response`/`_message_page_response`
159
+ (`views.py:50-74`) — REST-only, viewer-specific, from `MessageThreadReceipt`. `_with_reply_count`'s
160
+ list responses (`GET conversations/{id}/messages/`, `GET messages/{root_id}/thread/`) already attach
161
+ it per-row via one bulk query (not N+1) — so a normal `listMessages`/`listThread` load already gives
162
+ roots both their reply stats AND the viewer's own receipt in one response; no extra call needed on
163
+ mount.
164
+ - `POST messages/{root_id}/thread/read/` (`views.py:342-347`, already wired as `markThreadRead` in
165
+ `api.js:41`) returns `{"last_read_at": ...}` — **note the field name differs from the cached message's
166
+ own `thread_last_read_at` key** — the provider action must map `last_read_at` from this response onto
167
+ the root message's `thread_last_read_at` cache field, they are not the same key name.
168
+ - `serialize_conversation_core` (`serializers.py:107-115` area) now includes `external_key` (`null`
169
+ where the kind doesn't use one).
170
+
171
+ **The merge trap — investigated, resolved by existing infrastructure, still needs its mandatory test:**
172
+ `mergeById`/`reconcileMessage` (`MessagingProvider.jsx:45-79`) already do a **field-wise** merge
173
+ (`{...previous, ...incoming}`), and neither the `message` nor `message_edited` `applyFrame` branches
174
+ explicitly re-derive or strip `thread_last_read_at` the way the `reaction`/`poll_updated` branches used
175
+ to explicitly recompute their own fields — so an incoming frame lacking `thread_last_read_at` (it always
176
+ will) does NOT overwrite a previously-cached value through the current code path. This is a genuine
177
+ finding from investigation, not an assumption: **do not skip the mandatory merge-trap test on the
178
+ strength of this finding** — the WO requires it regardless, and any code added by this WO (the live
179
+ root-update below in particular) must preserve this property rather than accidentally break it by
180
+ constructing a replacement object that omits the key.
181
+
182
+ **Row 27 implementation:**
183
+ 1. `MessagingProvider.jsx`'s `markReplyThreadRead` (currently `:262`,
184
+ `const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt),
185
+ [api]);`) fires the REST call and **discards the response** — nothing ever updates the cache, so the
186
+ receipt is never actually stored client-side today. Fix: on success, patch the root message's
187
+ `thread_last_read_at` from the response's `last_read_at` (field-name mapping per above).
188
+ 2. A marker component/affordance on the thread-toggle button (`Thread.jsx`'s existing
189
+ `toggleReplies`/reply-count button, around where `message.reply_count` is already read at `:93-96`
190
+ for the reply-count label) — show it when `message.last_reply_at > message.thread_last_read_at`, OR
191
+ when `message.thread_last_read_at` is `null`/absent and `message.reply_count > 0`. No marker at all
192
+ when `reply_count === 0` (matches the existing "no toggle button at all when there are no replies"
193
+ behavior — don't add a marker to a control that doesn't render). This belongs in `Thread.jsx` (or a
194
+ small collaborator it owns) — not a new top-level component, per the non-goals' "marker belongs to
195
+ whichever component owns the thread toggle."
196
+ 3. **Live root update on an incoming reply** — in `applyFrame`'s `message` case
197
+ (`MessagingProvider.jsx:126-143`): after the existing reply message itself is merged via
198
+ `reconcileMessage`, additionally check `message.reply_to_id` (server field name — reuse the pattern
199
+ already established, this is NOT the client-shaped `reply_to` used only by locally-built optimistic
200
+ rows) against `state.messages` for a cached root. If found, bump that root's `reply_count` by one and
201
+ set `last_reply_at` to the incoming message's `created_at`, via a **field-wise merge that explicitly
202
+ preserves the root's existing `thread_last_read_at`** (the exact discipline the merge-trap section
203
+ above is about — do this by spreading the previous root object and only overriding
204
+ `reply_count`/`last_reply_at`, never by constructing a fresh object from the frame's data). Apply this
205
+ unconditionally (regardless of who sent the reply) — `reply_count`/`last_reply_at` are
206
+ viewer-independent facts; do not add sender-based suppression logic that isn't explicitly required
207
+ (the required tests only ask for the "sender is not the viewer" case, the common path — don't invent
208
+ untested self-send special-casing beyond that).
209
+
210
+ **Row 42 implementation:** `external_key` needs no new plumbing to reach the cache — `mergeById`/REST
211
+ responses pass every field through transparently already. What's missing is a way for a host to render
212
+ it. Add an optional host-supplied resolver prop to `ConversationList.jsx` (mirrors the existing
213
+ `groupLaunchers`/`broadcastLauncher` host-supplied-data pattern already in this file) — e.g.
214
+ `resolveManagedLabel(conversation)` — called instead of the existing `titleOf()` fallback specifically
215
+ when `conversation.kind === 'managed'` and the prop is supplied; falls back to the existing `titleOf()`
216
+ behavior otherwise (unset prop, or non-managed kind). **ucm must not hardcode or reference `event_all`/
217
+ `event_team` or any other jg-specific vocabulary anywhere in `src/`** — the resolver receives the raw
218
+ `conversation` object (including `external_key`) and the host owns all interpretation.
219
+
220
+ **Deviation doc + conformance test:** move rows 27 and 42 from `BLOCKED` to their delivered outcome in
221
+ `docs/messaging-deviations.md` (describe what was actually built, not a generic note, matching the
222
+ established style from MSG-3c's rewrite). This leaves **zero** `BLOCKED` entries — verify
223
+ `tests/messagingContractConformance.test.js`'s version-pin check (added in MSG-3c) still passes
224
+ meaningfully against an empty set (i.e. `unpinnedBlockedLines()` naturally returns `[]` when there are no
225
+ `**BLOCKED**` lines at all — confirm this is actually true of the regex-based approach, not just assumed
226
+ because the test happens to pass; the WO's own text calls out the "passing vacuously for the wrong
227
+ reason" risk explicitly).
228
+
229
+ **Required tests to WRITE (all named explicitly in the WO's own "Required tests" section — treat that
230
+ section as the checklist, not this paraphrase):**
231
+ - The merge-trap test (load-bearing, per the WO: server-shaped `message` and `message_edited` frames,
232
+ copied from real `serialize_message` shape, must leave a previously-fetched
233
+ `thread_last_read_at` intact).
234
+ - Marker visibility logic (shown/hidden/null-receipt/no-replies cases, all four named in the WO).
235
+ - Read-on-open (expanding a thread issues the REST call and updates the receipt from its response).
236
+ - Live root update (server-shaped `message` frame with `reply_to_id` raises the cached root's
237
+ `reply_count`/`last_reply_at`; marker appears without a refetch for the "sender is not the viewer"
238
+ case).
239
+ - Row 42: two managed conversations with different `external_key` render distinguishable labels via a
240
+ host resolver; **a direct assertion that no jg-specific string (`event_all`, `event_team`) appears
241
+ anywhere in `src/`** — grep-based, mirroring the style of the existing contract-conformance test.
242
+ - Deviation-doc/conformance: zero `BLOCKED` entries; the version-pin check's own logic still behaves
243
+ correctly against that empty set (not just "the real file happens to pass").
244
+ - Existing ucm suites stay green.
245
+
246
+ **Fixture rule (WO's own words, binding):** every payload in these tests is copied from actual dcm
247
+ serializer output — this workstream has twice shipped a bug that survived only because a hand-written
248
+ client-shaped fixture agreed with the client's own mistake (`reply_to` vs `reply_to_id`,
249
+ `last_message.body` vs `.excerpt`). Do not hand-invent a payload shape without checking it against the
250
+ serializer citations above first.
251
+
252
+ **Invariants / do-not-break:** don't touch chunks/rows already delivered; keep the ~400 LOC soft trigger
253
+ in mind for `Thread.jsx`/`ConversationList.jsx`/`MessagingProvider.jsx`; no new top-level component for
254
+ the marker (lives in `Thread.jsx` or a small collaborator it owns); no new DX-1 harness entry needed
255
+ unless a genuinely new independently-mountable component is added (it shouldn't be, per the above).
256
+
257
+ **Progress contract / preamble:** `PLAN:`/`PROGRESS:`/`RESULT:` lines, no git operations, write-and-run-
258
+ only-the-new-tests, leave the diff uncommitted for the orchestrator's review. Codex has been out of
259
+ workspace credits recently across the last several WOs in this workstream — verify liveness before
260
+ assuming success; if Codex fails (credits, quota, non-zero exit), the orchestrator implements directly
261
+ per the fallback rule, which makes the orchestrator the author and independent review mandatory
262
+ (not routine) — this has been true for every chunk of MSG-3c and should be expected here too.
263
+
264
+ **Mini-handover:** repo `C:\Users\biglmi\Documents\webapps\ui-core-micha`, branch `main`, WO
265
+ `work-orders/MSG-3d.md` (Part B above, single chunk). Follow `orchestrate-codex`. After the independent
266
+ review and commit, run the WO-end `ui_reviewer` pass (per the WO's "Reviews" section — confirm the
267
+ deviation doc reaches zero BLOCKED entries honestly, by delivery not deletion, and look at the marker
268
+ as a rendered visual affordance via the DX-1 harness), then one version bump + npm publish.