@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 +2 -1
- package/dev/entries.jsx +17 -2
- package/dist/i18n/messagingTranslations.js +1 -0
- package/dist/messaging/ConversationList.js +15 -3
- package/dist/messaging/MessagingProvider.js +37 -2
- package/dist/messaging/Thread.js +21 -2
- package/docs/messaging-deviations.md +10 -10
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +1 -0
- package/src/messaging/ConversationList.jsx +13 -3
- package/src/messaging/MessagingProvider.jsx +35 -2
- package/src/messaging/Thread.jsx +19 -2
- package/tests/messagingThreadReceipts.test.jsx +170 -0
- package/tests/messagingUnreadLifecycle.test.jsx +4 -1
- package/work-orders/MSG-3d.md +268 -0
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 |
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -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 &&
|
|
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. **
|
|
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. **
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
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.
|