@micha.bigler/ui-core-micha 2.17.0 → 2.18.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 +1 -1
- package/dist/i18n/messagingTranslations.js +1 -0
- package/dist/messaging/ConversationList.js +1 -1
- package/dist/messaging/MessagingProvider.js +43 -8
- package/dist/messaging/PollCard.js +22 -5
- package/docs/messaging-deviations.md +36 -19
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +1 -0
- package/src/messaging/ConversationList.jsx +1 -1
- package/src/messaging/MessagingProvider.jsx +41 -4
- package/src/messaging/PollCard.jsx +19 -3
- package/tests/ConversationList.test.jsx +1 -1
- package/tests/messagingContractConformance.test.js +27 -0
- package/tests/messagingInteractions.test.jsx +3 -1
- package/tests/messagingLastMessagePreview.test.jsx +93 -0
- package/tests/messagingPollRendering.test.jsx +115 -0
- package/tests/messagingReactionMerge.test.jsx +79 -0
- package/work-orders/MSG-3c.md +378 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -30,4 +30,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
30
30
|
| NOTIF-12 | Popup channel via the wizard renderer | Make `popup` a real delivery channel: extract the presentational dialog shell out of `OnboardingWizard`, render popup-eligible notifications through it, and record seen/dismissed on `NotificationRecipient` (design D-F7: renderer reuse only, NOT the onboarding progress store); dcm `PopupDispatcher` stops being a logging stub. Full spec: [work-orders/NOTIF-12.md](work-orders/NOTIF-12.md). | 2026-07-29 | done | ucm: c8e222f, d7bdcbe; dcm: django-core-micha 1612429, 82fd89f | Tier 2 (extracts a renderer cockpit+jg run in production; the roadmap label "uncritical" is about urgency, not blast radius). **HARD GATE: starts only after NOTIF-13 landed + published** — consumes its envelope contract and `subscribe()`, and edits the same ucm files (AGENTS.md overlap rule). **SHIPS WITH ZERO PRODUCERS, deliberately** (operator decision 2026-07-29, made with the evidence): no notification type declares `popup` in `eligible_channels` anywhere in dcm/jg/cockpit/hram/spesix/survey_app, so the channel is wired but inactive until an app opts a type in. WO therefore requires (a) CHANGELOG wording that does not overclaim (NOTIF-4 R1 precedent) and (b) a test-local producer type proving the path end-to-end, so it is not untested dead code. Was NOTIF-11 (renumbered 2026-07-29 for the jg cutover/drop split). **Gate satisfied 2026-07-30** (ucm 2.13.0 / dcm 2.33.0); WO re-verified against the landed NOTIF-13 code and corrected in `345631d`: NOTIF-13 shipped the envelope as a **domain**-level discriminator, so a popup payload built the same way as a chip payload would be indistinguishable from it - the WO now mandates a `channel` field inside the envelope (never a second envelope value) plus feed de-duplication by `notification_id`, without which chip+popup double-counts the unread badge. **Codex failed twice with the same genuine CLI/model-version error as NOTIF-13** (`gpt-5.6-terra requires a newer version of Codex`) — orchestrator implemented directly in Claude per the Codex→Claude fallback rule, both scopes. Independent `reviewer` (background agent, `fork_context=false`, full diff of both repos): found R1 (P1, real bug — the WS payload only carried `notification_id`, but `feed/mark/` resolves ids against `NotificationRecipient.pk`; every `markSeen`/`markDismissed` from a session-live-only popup would either no-op or corrupt an unrelated recipient row once a producer opts in — fixed by adding `recipient_id` to both `ChipDispatcher` and `PopupDispatcher` payloads, dcm-side, with regression tests), R2 (P2 — the reused dialog shell always showed the onboarding "Setup"/step-counter header even for a popup, a real mislabeling once any producer exists — fixed via an optional `title` override on `WizardDialogShell`, defaulting to the prior exact text so `OnboardingWizard` stays bit-for-bit unchanged; popup now shows its own notification title), R3 (P2 — coexistence only gated on a *blocking* onboarding step, so a non-blocking step (e.g. `browser_push`) and a popup could stack two simultaneous MUI dialogs — fixed by broadening the gate to any active onboarding step, with a new regression test). All three fixed and re-verified: ucm 88/88 vitest + `pnpm`/tsc build clean, dcm 95/95 pytest (notifications package). Published (once pushed): ucm 2.14.0 (npm, CI-gated on push to `main`), dcm 2.34.0 (PyPI, same). No app pin bumps in this WO per spec. Own orchestrator security/exposure pass in parallel with the reviewer: no findings (content rendering follows the existing `NotificationBell` i18n-interpolation pattern, no new WS consumer, `prefs.py`/`router.py`/`resolve_channels` untouched). |
|
|
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
|
-
| 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 |
|
|
33
|
+
| MSG-3b | Close the MSG-3 parity and contract gaps | Capability-checklist WO closing what MSG-3 missed: the entire unread lifecycle (mark-read never called — badges once raised never clear), message edit/delete + action menu, the DM launcher (`createDirectConversation` had no caller — the case MSG-2b unblocked in the backend), broken reply grouping (`serialize_message` emits `reply_to_id`, `Thread.jsx:40,62` reads `reply_to`, so every server-loaded reply renders as a root), quoted reply previews + jump-to-original, copy, client-side image compression, staged-image previews, upload progress, composer emoji insertion, relative timestamps, active-row highlight, announcement deep-link rendering. Plus a contract-conformance test and a corrected deviation list. | 2026-07-31 | done | c164db3, db8cd92, 61ed3b8, 7253172, fadb60a, 646fd01, 9748aca | Extends the landed MSG-3 (2.16.0), convention as MSG-2b/NOTIF-8b. Envelope: [work-orders/MSG-3b.md](work-orders/MSG-3b.md). Tier 2. **Origin: a three-way audit** (jg capability baseline / ucm wired-vs-adapter-only inventory / dcm contract-coverage sweep) run after MSG-3 was marked done. MSG-3 had 139/139 green, five per-chunk reviews and a clean `ui_reviewer`, and still missed all of it — **because both sides were tested against themselves**: dcm against its services, ucm against a mock whose surface mirrors the provider rather than the contract, so an endpoint with no caller is structurally invisible. Second cause: "no jg feature is lost" was made binding without anyone writing the feature list first, and the deviation list meant to prove parity was authored by the party doing the work; its "Final parity confirmation" section claims completeness and is demonstrably false. This WO's checklist IS the spec (59 rows, each OK/DEV/MISSING/BROKEN/PARTIAL/BLOCKED; the review corrected one false OK — row 5, unread never live-updates at all); the contract-conformance test is the systemic fix. **Rows 38, 51-53, 56-58 are BLOCKED on dcm** (`MSG-2c`, not yet cut): no `serialize_poll` exists at all so `PollCard` reads fields no endpoint returns — and the design specifies no poll read path either, so `messaging-platform.md` must be amended first; `services.py` has 3 `_publish` call sites for 12 designed frames, so reaction/poll live sync cannot work; `serialize_conversation` omits `last_message`, so the list preview is permanently empty. Carries `ui_reviewer` at WO end, which must check the corrected deviation list against the checklist row by row. **All 6 chunks implemented, independently reviewed and committed** (chunk 1: unread lifecycle end-to-end + contract-conformance test, own-fix for a markThreadRead failure silently blocking thread-open; chunk 2: `reply_to`/`reply_to_id` fix + quoting/jump-to-original, row 27 found and recorded BLOCKED (no dcm thread-receipt read path) after an independent-review P1 caught it being silently dropped first time; chunk 3: message action menu/edit/delete/copy with a host-supplied moderator capability prop (dcm exposes no server-side capability field), fixed an undefined-vs-undefined author-match bug; chunk 4: `DirectMessageLauncher` wiring `createDirectConversation`, fixed jest-dom-matcher and MUI-dialog-transition-timing test issues after Codex's own verification pass died without a `RESULT:` line; chunk 5: composer shortcuts/previews/compression/emoji, fixed Codex settling for indeterminate-only upload progress by threading real `onUploadProgress` through the adapter as the WO asked, fixed missing diacritics in new translations (same bug class caught once before in MSG-3), corrected an unverified poll/attachment-mutual-exclusivity claim before it could land in the deviation doc; chunk 6: list/timeline polish (relative timestamps, auto-scroll, announcement links, DM sender-suppression, read-receipt popover) plus the full deviation-doc rewrite against all 59 checklist rows — fixed a repeat of an already-once-fixed raw-interactive-element pattern, corrected an overclaiming doc line, fixed a stale-ref edge case in auto-scroll). Row 42 (managed all-vs-team) also verified genuinely BLOCKED — dcm has `external_key` but never serializes it. **WO-end `ui_reviewer`** (independent, row-by-row cross-check of `docs/messaging-deviations.md` against landed code for all 59 checklist rows, per the WO's binding "the review that signed off the false completeness claim is the one this WO exists to correct" instruction): no findings — every OK/DEV/BLOCKED row verified against actual code, not against the doc's own claims; translation coverage, DX-1 harness coverage, design-system/dark-mode/reuse all checked clean. Published: **ucm 2.17.0** (npm, CI-gated on push to `main`, `9748aca`). No CHANGELOG.md in this repo (same as NOTIF-12/NOTIF-13/MSG-3) — documented via commit messages + this register entry only. No app pin bumps in this WO. |
|
|
34
|
+
| MSG-3c | Deliver the rows MSG-3b left blocked against a stale dcm version | Seven checklist rows (38, 51-53, 56-58) that MSG-3b correctly refused to fake against dcm 2.36.1, and which dcm 2.37.0 made buildable **while MSG-3b was still running**: real last-message preview + list reorder/preview refresh, full `PollCard` rendering (question / options / `vote_count` / `voters` / closed state), and the `reaction` / `poll_updated` frame handlers that stop being dead code. Plus the deviation-doc correction and a conformance-test extension requiring every BLOCKED entry to carry a dependency version pin. | 2026-07-31 | in-review | 84cc3c2, 5dd4d3a, 733588a | Envelope: [work-orders/MSG-3c.md](work-orders/MSG-3c.md); canonical row in dcm's register. Extends the landed MSG-3b (2.17.0). **Root cause — third occurrence of one pattern in this workstream:** the WO-end `ui_reviewer` cross-checked all 59 rows against *ucm's* landed code and took the dependency's state from the WO text instead of reading dcm, so seven rows closed as "blocked on a future dcm work order" that had already shipped. New rule this WO adds: a BLOCKED row is re-verified against the live dependency **at WO close, not at WO start**, and every BLOCKED entry names the version it was blocked at. MSG-3b already did the version-pinning unprompted — that pin is the only reason the staleness was a two-minute check rather than surfacing at MSG-5. Rows 27 and 42 stay correctly BLOCKED, on dcm `MSG-2d`. Highest latent risk: rows 56/57 are handler paths that have **never** run against a real frame, so treat them as new code under test rather than as working code being unblocked; and poll tests must use server-shaped payloads (`serialize_poll` output), since client-shaped fixtures are what let the `reply_to_id` bug survive MSG-3. **Codex was out of credits for this entire WO** (verified: every invocation returned only a workspace-credits error, no work done) — all three chunks implemented directly by the orchestrator per the Codex→Claude fallback rule, with mandatory independent review on every chunk (author ≠ reviewer). **Chunk 1** (rows 38/58): fixed a real bug found while scoping — `ConversationList` read `last_message.body`, but dcm's `serialize_last_message` only ever returns `.excerpt` — plus a defensive fix to `applyFrame`'s `message_edited` keying (reviewer found my initial bug analysis incomplete: dcm always attaches a full `frame.message`, so the fix is a no-op against the real wire contract but kept as genuine defensive robustness; the regression test was rewritten to use the real enriched frame shape instead of a fabricated minimal one). **Chunk 2** (rows 51-53): fixed `PollCard` reading non-existent `created_by`/`selected`/`voted` fields (real fields: `created_by_id`, `voted_option_ids` — the latter present only on the vote/create/close REST responses, never in `serialize_poll` or the `poll_updated` frame); found and fixed the same "frame wholesale-replaces viewer-specific state" defect class in `poll_updated`'s own handler ahead of chunk 3's explicit scope. **Chunk 3** (rows 56/57 + WO deliverables): found and fixed the identical defect in the `reaction` frame handler and `toggleReaction`'s REST-confirm path (dcm's `serialize_reactions` never returns a per-viewer `reacted` flag; both call sites were wholesale-replacing and silently wiping known reacted state); extended the contract-conformance test with the WO's new BLOCKED-version-pin rule plus a required negative-case test; rewrote `docs/messaging-deviations.md` (rows 38/51/53/56-58 → OK, row 52 → DEV, rows 27/42 stay BLOCKED with the new version pin, substance untouched). Status `in-review`, not yet `done`: WO-end `ui_reviewer` pass (per the WO's changed instruction — cross-check BLOCKED rows against dcm's *current* version) and the version-bump/publish step are still outstanding. |
|
package/dev/entries.jsx
CHANGED
|
@@ -61,7 +61,7 @@ function RealtimeAdapterEntry() {
|
|
|
61
61
|
const messagingHarnessApi = {
|
|
62
62
|
listConversations: async ({ cursor } = {}) => cursor
|
|
63
63
|
? ({ results: [{ id: 13, title: 'Archived planning', last_message_at: '2026-07-30T09:00:00Z' }], next_cursor: null })
|
|
64
|
-
: ({ results: [{ id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: {
|
|
64
|
+
: ({ results: [{ id: 12, title: 'Support group', unread_count: 1, last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Can someone help?' } }], next_cursor: 'signed-next-page' }),
|
|
65
65
|
// GET conversations/{id}/messages/ returns roots only (design §REST); replies
|
|
66
66
|
// come from GET messages/{root_id}/thread/ (listThread below), never mixed in here.
|
|
67
67
|
listMessages: async (conversationId, { cursor } = {}) => cursor
|
|
@@ -85,6 +85,7 @@ export const messagingTranslations = {
|
|
|
85
85
|
'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
|
|
86
86
|
'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
|
|
87
87
|
'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
88
|
+
'MessagingPoll.YOU_VOTED': { de: 'von dir gewählt', en: 'voted by you', fr: 'voté par vous' },
|
|
88
89
|
'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
89
90
|
'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
90
91
|
'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
|
|
@@ -77,6 +77,6 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
77
77
|
var _a;
|
|
78
78
|
const unread = unreadFor(conversation);
|
|
79
79
|
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.
|
|
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);
|
|
81
81
|
}) })), 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
82
|
}
|
|
@@ -92,7 +92,7 @@ export function messagingReducer(state, action) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
export function applyFrame(state, frame, activeConversationId = null) {
|
|
95
|
-
var _a, _b, _c, _d;
|
|
95
|
+
var _a, _b, _c, _d, _e, _f;
|
|
96
96
|
const payload = frame.message || frame.conversation || frame.participant || frame;
|
|
97
97
|
const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
|
|
98
98
|
if (frame.type === 'message') {
|
|
@@ -106,17 +106,45 @@ export function applyFrame(state, frame, activeConversationId = null) {
|
|
|
106
106
|
const previousCount = ((_b = unread.by_conversation) === null || _b === void 0 ? void 0 : _b[conversationId]) || 0;
|
|
107
107
|
return Object.assign(Object.assign({}, state), { messages, unread: Object.assign(Object.assign({}, unread), { unread_count: (unread.unread_count || 0) + 1, by_conversation: Object.assign(Object.assign({}, unread.by_conversation), { [conversationId]: previousCount + 1 }) }) });
|
|
108
108
|
}
|
|
109
|
-
if (frame.type === 'message_edited')
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
if (frame.type === 'message_edited') {
|
|
110
|
+
// dcm's publish_messaging_event always attaches a full frame.message
|
|
111
|
+
// (serialize_message) for this event type, so `payload` already carries
|
|
112
|
+
// the message's own `.id` in practice. The explicit `id:` override below
|
|
113
|
+
// is defensive only — it keeps this branch correct even if a future
|
|
114
|
+
// producer ever omits `.message` and falls through to the raw frame,
|
|
115
|
+
// whose `conversation_id` would otherwise win idOf()'s fallback order.
|
|
112
116
|
const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
|
|
117
|
+
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, payload), { id: messageId })) });
|
|
118
|
+
}
|
|
119
|
+
if (frame.type === 'reaction') {
|
|
120
|
+
const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
|
|
113
121
|
const previous = state.messages[messageId] || { id: messageId };
|
|
114
|
-
|
|
122
|
+
// serialize_reactions (dcm) returns [{emoji, count}] only, never a
|
|
123
|
+
// per-viewer `reacted` flag, in any context — not REST, not frames, by
|
|
124
|
+
// design (aggregate-only). Wholesale-replacing `reactions` with the
|
|
125
|
+
// incoming server list would silently wipe every previously-known
|
|
126
|
+
// `reacted` flag on this message, not just whichever emoji changed.
|
|
127
|
+
// Merge by emoji instead: carry forward a previously-known `reacted`
|
|
128
|
+
// flag for any emoji still present in the incoming aggregate.
|
|
129
|
+
const incoming = payload.reactions || frame.reactions || previous.reactions || [];
|
|
130
|
+
const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
|
|
131
|
+
const reactions = incoming.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = previousByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); });
|
|
132
|
+
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions })) });
|
|
115
133
|
}
|
|
116
134
|
if (frame.type === 'poll_updated') {
|
|
117
|
-
const messageId = (
|
|
135
|
+
const messageId = (_e = frame.message_id) !== null && _e !== void 0 ? _e : payload.message_id;
|
|
118
136
|
const previous = state.messages[messageId] || { id: messageId };
|
|
119
|
-
const
|
|
137
|
+
const incomingPoll = payload.poll || frame.poll || payload;
|
|
138
|
+
// dcm's poll_updated frame is a viewer-independent projection
|
|
139
|
+
// (serialize_poll) — it never carries voted_option_ids, which dcm only
|
|
140
|
+
// ever adds to this viewer's own vote/create/close REST response. A
|
|
141
|
+
// frame arriving after a REST-derived vote is known must not wipe that
|
|
142
|
+
// locally-known selection; carry it forward when the frame is silent
|
|
143
|
+
// about it, never invert it from frame data that structurally can't
|
|
144
|
+
// contain it.
|
|
145
|
+
const previousVotedOptionIds = (_f = previous.poll) === null || _f === void 0 ? void 0 : _f.voted_option_ids;
|
|
146
|
+
const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
|
|
147
|
+
? Object.assign(Object.assign({}, incomingPoll), { voted_option_ids: previousVotedOptionIds }) : incomingPoll;
|
|
120
148
|
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
|
|
121
149
|
}
|
|
122
150
|
if (frame.type === 'message_deleted') {
|
|
@@ -284,7 +312,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
284
312
|
patchMessage(Object.assign(Object.assign({}, message), { reactions: nextReactions }));
|
|
285
313
|
try {
|
|
286
314
|
const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
|
|
287
|
-
|
|
315
|
+
// Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
|
|
316
|
+
// never returns a per-viewer `reacted` flag) — merge the server's confirmed
|
|
317
|
+
// counts with the reacted flags nextReactions already computed correctly,
|
|
318
|
+
// rather than letting the reacted-less server array wipe them.
|
|
319
|
+
const confirmedCounts = (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions);
|
|
320
|
+
const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
|
|
321
|
+
const reactions = confirmedCounts ? confirmedCounts.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = nextByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); }) : nextReactions;
|
|
322
|
+
patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions }));
|
|
288
323
|
return result;
|
|
289
324
|
}
|
|
290
325
|
catch (error) {
|
|
@@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
6
|
/** Independently mountable live poll view. Server authorization remains authoritative. */
|
|
7
7
|
export function PollCard({ message, canClose: canCloseProp }) {
|
|
8
|
-
var _a, _b, _c, _d, _e, _f;
|
|
8
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
9
9
|
const { t } = useTranslation();
|
|
10
10
|
const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
|
|
11
11
|
const poll = message === null || message === void 0 ? void 0 : message.poll;
|
|
12
12
|
const [selected, setSelected] = useState([]);
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const [busy, setBusy] = useState(false);
|
|
15
|
-
|
|
15
|
+
// voted_option_ids is a view-level enrichment dcm adds ONLY to the
|
|
16
|
+
// create/vote/close poll REST responses (serialize_poll itself, every
|
|
17
|
+
// message-list/thread load, and every poll_updated realtime frame never
|
|
18
|
+
// carry it) — so it's only ever present right after this viewer's own
|
|
19
|
+
// vote/create/close action. When it's absent (a freshly loaded or
|
|
20
|
+
// frame-updated poll), there's no way to know this viewer's vote from the
|
|
21
|
+
// payload alone; default to no selection rather than guessing or carrying
|
|
22
|
+
// over stale local state.
|
|
23
|
+
useEffect(() => setSelected((poll === null || poll === void 0 ? void 0 : poll.voted_option_ids) ? [...poll.voted_option_ids] : []), [poll]);
|
|
16
24
|
if (!poll)
|
|
17
25
|
return null;
|
|
18
26
|
const closed = Boolean(poll.closed_at);
|
|
19
|
-
const creatorId = (
|
|
20
|
-
const canClose = (
|
|
27
|
+
const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
|
|
28
|
+
const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
|
|
21
29
|
const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
|
|
22
30
|
const vote = async () => {
|
|
23
31
|
if (!selected.length) {
|
|
@@ -49,5 +57,14 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
49
57
|
setBusy(false);
|
|
50
58
|
}
|
|
51
59
|
};
|
|
52
|
-
return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
|
|
60
|
+
return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
|
|
61
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
62
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
63
|
+
// is no user-directory endpoint (same structural limit already
|
|
64
|
+
// established for the DM candidate list). A count is always correct;
|
|
65
|
+
// marking the viewer's own vote is derivable from voted_option_ids.
|
|
66
|
+
const votedByMe = Boolean((_a = poll.voted_option_ids) === null || _a === void 0 ? void 0 : _a.includes(option.id));
|
|
67
|
+
const voterCount = (_e = (_d = (_c = (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : option.vote_count) !== null && _d !== void 0 ? _d : option.votes_count) !== null && _e !== void 0 ? _e : 0;
|
|
68
|
+
return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_g = (_f = option.vote_count) !== null && _f !== void 0 ? _f : voterCount) !== null && _g !== void 0 ? _g : 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}` }, option.id);
|
|
69
|
+
}), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
|
|
53
70
|
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
# Messaging parity outcomes (MSG-3b)
|
|
2
|
-
|
|
3
|
-
This is the authoritative
|
|
4
|
-
file-level “final parity confirmation,” which was not
|
|
5
|
-
feature parity. Each entry maps directly to the
|
|
6
|
-
of `work-orders/MSG-3b.md`. `OK` means the
|
|
7
|
-
an intentional, documented redesign or
|
|
8
|
-
|
|
1
|
+
# Messaging parity outcomes (MSG-3b, extended by MSG-3c)
|
|
2
|
+
|
|
3
|
+
This is the authoritative parity record for the ucm messaging surfaces. It
|
|
4
|
+
replaces the former file-level “final parity confirmation,” which was not
|
|
5
|
+
sufficient evidence of feature parity. Each entry maps directly to the
|
|
6
|
+
numbered checklist in Part A of `work-orders/MSG-3b.md`. `OK` means the
|
|
7
|
+
capability is present; `DEV` means an intentional, documented redesign or
|
|
8
|
+
non-parity; `BLOCKED` means the dependency (dcm) does not expose the data or
|
|
9
|
+
realtime contract needed to implement it truthfully.
|
|
10
|
+
|
|
11
|
+
**Binding rule, added by MSG-3c:** every `BLOCKED` entry below must name the
|
|
12
|
+
dcm version it was verified blocked against. MSG-3b was itself blocked
|
|
13
|
+
against dcm 2.36.1 and recorded seven rows as `BLOCKED` on "a future dcm work
|
|
14
|
+
order" — dcm 2.37.0 shipped that work mid-flight, and MSG-3b's own WO-end
|
|
15
|
+
review closed without noticing, because it cross-checked the checklist
|
|
16
|
+
against ucm's landed code and took the dependency's state from the WO text
|
|
17
|
+
instead of the live dependency. A version pin turns that re-check into a
|
|
18
|
+
two-minute job instead of an archaeology exercise; `tests/messagingContractConformance.test.js`
|
|
19
|
+
now fails the build if a `BLOCKED` line omits one.
|
|
9
20
|
|
|
10
21
|
## Unread lifecycle
|
|
11
22
|
|
|
@@ -44,7 +55,7 @@ does not expose the data or realtime contract needed to implement it truthfully.
|
|
|
44
55
|
24. **OK** — Replies show a sender and short quoted snippet.
|
|
45
56
|
25. **OK** — Clicking a quote scrolls its source message into view.
|
|
46
57
|
26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
|
|
47
|
-
27. **BLOCKED** — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition.
|
|
58
|
+
27. **BLOCKED** (blocked as of dcm 2.37.0) — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
|
|
48
59
|
28. **OK** — Reply threads load lazily on expand and collapse locally.
|
|
49
60
|
29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
|
|
50
61
|
|
|
@@ -61,11 +72,11 @@ does not expose the data or realtime contract needed to implement it truthfully.
|
|
|
61
72
|
|
|
62
73
|
## Conversation list
|
|
63
74
|
|
|
64
|
-
38. **
|
|
75
|
+
38. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 serializes `last_message` (`excerpt`/`created_at`) on the conversation payload and emits it live via `conversation_upsert` on new-message send. `ConversationList` was found reading the wrong field name (`last_message.body` instead of the real `excerpt`) — fixed, so the preview now actually renders against the real contract instead of always falling back to the empty state. Reorder and live preview refresh work for new messages; dcm does not publish `conversation_upsert` on message edit/delete, so those cases update on the next REST refresh rather than live (see row 58).
|
|
65
76
|
39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
|
|
66
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.
|
|
67
78
|
41. **OK** — Unread badges and bold unread titles are present.
|
|
68
|
-
42. **BLOCKED** — dcm stores the managed all/team distinction in `external_key
|
|
79
|
+
42. **BLOCKED** (blocked as of dcm 2.37.0) — dcm stores the managed all/team distinction in `external_key`, but `serialize_conversation_core` still omits it from every conversation payload. The UI must not guess from titles or other unrelated fields. Re-verified against 2.37.0 (MSG-3c): still absent. Waits on dcm `MSG-2d`.
|
|
69
80
|
43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
|
|
70
81
|
44. **OK** — Mute and archive actions are present.
|
|
71
82
|
|
|
@@ -83,19 +94,25 @@ does not expose the data or realtime contract needed to implement it truthfully.
|
|
|
83
94
|
|
|
84
95
|
## Polls
|
|
85
96
|
|
|
86
|
-
51. **
|
|
87
|
-
52. **
|
|
88
|
-
53. **
|
|
97
|
+
51. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's `serialize_poll` renders question, options and per-option `vote_count` through `PollCard`. Fixed against the real shape: the initial-selection effect previously read non-existent per-option `selected`/`voted` flags — it now derives from `voted_option_ids`, which dcm adds only to the create/vote/close REST responses (never to `serialize_poll` itself or the `poll_updated` frame), and correctly shows no known selection when that field is absent rather than guessing.
|
|
98
|
+
52. **DEV** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0's poll options carry `voters` as user ids only, never names, and there is no user-directory endpoint (the same structural limit already established for the DM candidate list, row 19). `PollCard` renders the vote count and marks the viewer's own vote (derived from `voted_option_ids`) rather than a per-voter name list, which the dependency cannot supply.
|
|
99
|
+
53. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — `closed_at` is in the real poll projection; voting is disabled once set. The poll creator's close permission was found broken against the real contract (`created_by_id` is the real field; the prior code read a non-existent `created_by.id`) — fixed.
|
|
89
100
|
54. **OK** — Composer starts at two options and disables adding options once it reaches ten; the server’s two-option minimum remains enforced.
|
|
90
|
-
55. **OK** — Vote and close write paths are wired;
|
|
101
|
+
55. **OK** — Vote and close write paths are wired; full read rendering is now delivered per rows 51-53.
|
|
91
102
|
|
|
92
103
|
## Live synchronization
|
|
93
104
|
|
|
94
|
-
56. **
|
|
95
|
-
57. **
|
|
96
|
-
58. **
|
|
105
|
+
56. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `reaction` frames. The handler had never run against a real frame; verification found it wholesale-replacing the message's reaction list with the server's aggregate-only projection (`{emoji, count}`, never a per-viewer `reacted` flag, by design) on every arrival — silently wiping every previously-known `reacted` flag on that message, not just whichever emoji changed. The same defect existed in the REST-confirm path for the viewer's own toggle. Both fixed to merge by emoji, preserving previously-known `reacted` state instead of discarding it.
|
|
106
|
+
57. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — dcm 2.37.0 emits `poll_updated` frames. Same defect class as row 56, found while implementing row 51-53: the frame handler wholesale-replaced a message's `poll` object, silently dropping a REST-known `voted_option_ids` the moment any `poll_updated` frame arrived (e.g. a vote-count bump from another viewer). Fixed to carry the previous value forward when the incoming frame is silent about it, per the WO's explicit "must not clear or invert the viewer's own vote state" requirement.
|
|
107
|
+
58. **OK** (delivered in MSG-3c, was BLOCKED as of dcm 2.36.1) — new messages live-reorder the conversation list and refresh its preview via `conversation_upsert` (row 38). dcm does not publish `conversation_upsert` on message edit or delete (only on new-message send — verified against every `_publish(..., "conversation_upsert", ...)` call site in `services.py`), so an edit/delete of a conversation's last message updates the preview on the next REST refresh rather than live; this is a dcm-side gap outside this WO's scope, not a ucm limitation.
|
|
97
108
|
59. **OK** — dcm’s emitted message, message-edited, and message-deleted frames are handled live.
|
|
98
109
|
|
|
99
110
|
## Required backend follow-up
|
|
100
111
|
|
|
101
|
-
Rows 27
|
|
112
|
+
Rows 27 and 42 require a future dcm work order (`MSG-2d`): expose readable thread receipt state, and
|
|
113
|
+
serialize `external_key` where managed all/team labeling is required. Until then, these are explicit
|
|
114
|
+
BLOCKED outcomes, each pinned to the dcm version they were verified blocked against, rather than
|
|
115
|
+
simulated client behavior. MSG-2c (dcm 2.37.0) already delivered what MSG-3b's own audit had recorded
|
|
116
|
+
as blocking rows 38, 51–53 and 56–58 — MSG-3c re-verified each against the live dependency and
|
|
117
|
+
delivered them; see those rows above for what was actually built (in three cases, fixing a real
|
|
118
|
+
client-side bug found only once real server data was checked against, not just unblocked).
|
package/package.json
CHANGED
|
@@ -85,6 +85,7 @@ export const messagingTranslations = {
|
|
|
85
85
|
'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
|
|
86
86
|
'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
|
|
87
87
|
'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
88
|
+
'MessagingPoll.YOU_VOTED': { de: 'von dir gewählt', en: 'voted by you', fr: 'voté par vous' },
|
|
88
89
|
'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
89
90
|
'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
90
91
|
'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
|
|
@@ -68,7 +68,7 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
|
|
|
68
68
|
<Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
|
|
69
69
|
{archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
|
|
70
70
|
</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?.
|
|
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 }} />
|
|
72
72
|
<IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
|
|
73
73
|
</ListItemButton>;
|
|
74
74
|
})}
|
|
@@ -141,16 +141,46 @@ export function applyFrame(state, frame, activeConversationId = null) {
|
|
|
141
141
|
},
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
|
-
if (frame.type === 'message_edited')
|
|
144
|
+
if (frame.type === 'message_edited') {
|
|
145
|
+
// dcm's publish_messaging_event always attaches a full frame.message
|
|
146
|
+
// (serialize_message) for this event type, so `payload` already carries
|
|
147
|
+
// the message's own `.id` in practice. The explicit `id:` override below
|
|
148
|
+
// is defensive only — it keeps this branch correct even if a future
|
|
149
|
+
// producer ever omits `.message` and falls through to the raw frame,
|
|
150
|
+
// whose `conversation_id` would otherwise win idOf()'s fallback order.
|
|
151
|
+
const messageId = frame.message_id ?? payload.message_id;
|
|
152
|
+
return { ...state, messages: mergeById(state.messages, { ...payload, id: messageId }) };
|
|
153
|
+
}
|
|
145
154
|
if (frame.type === 'reaction') {
|
|
146
155
|
const messageId = frame.message_id ?? payload.message_id;
|
|
147
156
|
const previous = state.messages[messageId] || { id: messageId };
|
|
148
|
-
|
|
157
|
+
// serialize_reactions (dcm) returns [{emoji, count}] only, never a
|
|
158
|
+
// per-viewer `reacted` flag, in any context — not REST, not frames, by
|
|
159
|
+
// design (aggregate-only). Wholesale-replacing `reactions` with the
|
|
160
|
+
// incoming server list would silently wipe every previously-known
|
|
161
|
+
// `reacted` flag on this message, not just whichever emoji changed.
|
|
162
|
+
// Merge by emoji instead: carry forward a previously-known `reacted`
|
|
163
|
+
// flag for any emoji still present in the incoming aggregate.
|
|
164
|
+
const incoming = payload.reactions || frame.reactions || previous.reactions || [];
|
|
165
|
+
const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
|
|
166
|
+
const reactions = incoming.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? previousByEmoji.get(reaction.emoji)?.reacted ?? false }));
|
|
167
|
+
return { ...state, messages: mergeById(state.messages, { ...previous, reactions }) };
|
|
149
168
|
}
|
|
150
169
|
if (frame.type === 'poll_updated') {
|
|
151
170
|
const messageId = frame.message_id ?? payload.message_id;
|
|
152
171
|
const previous = state.messages[messageId] || { id: messageId };
|
|
153
|
-
const
|
|
172
|
+
const incomingPoll = payload.poll || frame.poll || payload;
|
|
173
|
+
// dcm's poll_updated frame is a viewer-independent projection
|
|
174
|
+
// (serialize_poll) — it never carries voted_option_ids, which dcm only
|
|
175
|
+
// ever adds to this viewer's own vote/create/close REST response. A
|
|
176
|
+
// frame arriving after a REST-derived vote is known must not wipe that
|
|
177
|
+
// locally-known selection; carry it forward when the frame is silent
|
|
178
|
+
// about it, never invert it from frame data that structurally can't
|
|
179
|
+
// contain it.
|
|
180
|
+
const previousVotedOptionIds = previous.poll?.voted_option_ids;
|
|
181
|
+
const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
|
|
182
|
+
? { ...incomingPoll, voted_option_ids: previousVotedOptionIds }
|
|
183
|
+
: incomingPoll;
|
|
154
184
|
return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
|
|
155
185
|
}
|
|
156
186
|
if (frame.type === 'message_deleted') {
|
|
@@ -324,7 +354,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
324
354
|
patchMessage({ ...message, reactions: nextReactions });
|
|
325
355
|
try {
|
|
326
356
|
const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
|
|
327
|
-
|
|
357
|
+
// Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
|
|
358
|
+
// never returns a per-viewer `reacted` flag) — merge the server's confirmed
|
|
359
|
+
// counts with the reacted flags nextReactions already computed correctly,
|
|
360
|
+
// rather than letting the reacted-less server array wipe them.
|
|
361
|
+
const confirmedCounts = result?.reactions || result?.message?.reactions;
|
|
362
|
+
const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
|
|
363
|
+
const reactions = confirmedCounts ? confirmedCounts.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? nextByEmoji.get(reaction.emoji)?.reacted ?? false })) : nextReactions;
|
|
364
|
+
patchMessage({ ...message, ...(result?.message || result), reactions });
|
|
328
365
|
return result;
|
|
329
366
|
} catch (error) { patchMessage(message); throw error; }
|
|
330
367
|
}, [api, cache.messages, patchMessage]);
|
|
@@ -12,10 +12,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
12
12
|
const [selected, setSelected] = useState([]);
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const [busy, setBusy] = useState(false);
|
|
15
|
-
|
|
15
|
+
// voted_option_ids is a view-level enrichment dcm adds ONLY to the
|
|
16
|
+
// create/vote/close poll REST responses (serialize_poll itself, every
|
|
17
|
+
// message-list/thread load, and every poll_updated realtime frame never
|
|
18
|
+
// carry it) — so it's only ever present right after this viewer's own
|
|
19
|
+
// vote/create/close action. When it's absent (a freshly loaded or
|
|
20
|
+
// frame-updated poll), there's no way to know this viewer's vote from the
|
|
21
|
+
// payload alone; default to no selection rather than guessing or carrying
|
|
22
|
+
// over stale local state.
|
|
23
|
+
useEffect(() => setSelected(poll?.voted_option_ids ? [...poll.voted_option_ids] : []), [poll]);
|
|
16
24
|
if (!poll) return null;
|
|
17
25
|
const closed = Boolean(poll.closed_at);
|
|
18
|
-
const creatorId = poll.created_by?.id ?? poll.created_by;
|
|
26
|
+
const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
|
|
19
27
|
const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
|
|
20
28
|
const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
|
|
21
29
|
const vote = async () => {
|
|
@@ -29,7 +37,15 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
29
37
|
};
|
|
30
38
|
return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
|
|
31
39
|
<Typography variant="subtitle2">{poll.question}</Typography>
|
|
32
|
-
{(poll.options || []).map((option) =>
|
|
40
|
+
{(poll.options || []).map((option) => {
|
|
41
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
42
|
+
// is no user-directory endpoint (same structural limit already
|
|
43
|
+
// established for the DM candidate list). A count is always correct;
|
|
44
|
+
// marking the viewer's own vote is derivable from voted_option_ids.
|
|
45
|
+
const votedByMe = Boolean(poll.voted_option_ids?.includes(option.id));
|
|
46
|
+
const voterCount = option.voters?.length ?? option.vote_count ?? option.votes_count ?? 0;
|
|
47
|
+
return <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? voterCount ?? 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}`} />;
|
|
48
|
+
})}
|
|
33
49
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
34
50
|
<Stack direction="row" spacing={1}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
|
|
35
51
|
</Stack></Paper>;
|
|
@@ -17,7 +17,7 @@ describe('ConversationList', () => {
|
|
|
17
17
|
let api;
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
api = {
|
|
20
|
-
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: {
|
|
20
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { excerpt: 'Hello' } }], next_cursor: 'opaque-cursor' }),
|
|
21
21
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
|
|
22
22
|
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
23
|
archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
|
|
@@ -21,6 +21,33 @@ const FRAME_EXEMPTIONS = {
|
|
|
21
21
|
thread_read_state: 'Receipt synchronisation is outside MSG-3b.',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
const BLOCKED_LINE = /\*\*BLOCKED\*\*/;
|
|
25
|
+
const VERSION_PIN = /dcm \d+\.\d+\.\d+/;
|
|
26
|
+
|
|
27
|
+
// A BLOCKED row without a version pin is exactly how MSG-3b's seven blocked
|
|
28
|
+
// rows went stale unnoticed: dcm shipped what they needed mid-flight and the
|
|
29
|
+
// WO-end review had no cheap way to tell. This check can't know whether dcm
|
|
30
|
+
// has since shipped — it only guarantees the pin exists, which is what turns
|
|
31
|
+
// the re-check into a two-minute job instead of an archaeology exercise.
|
|
32
|
+
function unpinnedBlockedLines(markdown) {
|
|
33
|
+
return markdown.split('\n').filter((line) => BLOCKED_LINE.test(line) && !VERSION_PIN.test(line));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('messaging deviation-doc BLOCKED entries carry a dependency version pin', () => {
|
|
37
|
+
it('every BLOCKED line in docs/messaging-deviations.md names the dcm version it was blocked at', () => {
|
|
38
|
+
const markdown = fs.readFileSync(path.join(root, 'docs', 'messaging-deviations.md'), 'utf8');
|
|
39
|
+
expect(unpinnedBlockedLines(markdown)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Required negative case (the WO is explicit: prove the check can fail,
|
|
43
|
+
// not only that the real file happens to pass).
|
|
44
|
+
it('flags a BLOCKED line that omits the version pin', () => {
|
|
45
|
+
const unpinned = '99. **BLOCKED** — some capability with no dependency version named.';
|
|
46
|
+
const pinned = '100. **BLOCKED** (blocked as of dcm 2.37.0) — some other capability.';
|
|
47
|
+
expect(unpinnedBlockedLines(`${unpinned}\n${pinned}`)).toEqual([unpinned]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
24
51
|
describe('messaging adapter and realtime contract conformance', () => {
|
|
25
52
|
it('has a caller for every API export unless it has an explicit rationale', () => {
|
|
26
53
|
const apiSource = fs.readFileSync(path.join(messagingRoot, 'api.js'), 'utf8');
|
|
@@ -19,7 +19,9 @@ afterEach(cleanup);
|
|
|
19
19
|
|
|
20
20
|
describe('messaging reactions and polls', () => {
|
|
21
21
|
it('round-trips add and remove reactions through the cache-backed API', async () => {
|
|
22
|
-
|
|
22
|
+
// serialize_reactions (dcm) never returns a per-viewer `reacted` flag —
|
|
23
|
+
// {emoji, count} only, in every context.
|
|
24
|
+
const api = baseApi(); api.addReaction.mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }); api.removeReaction.mockResolvedValue({ reactions: [] });
|
|
23
25
|
const { rerender } = render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|
|
24
26
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith(7, '👍'));
|
|
25
27
|
rerender(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: true }] }} /></MessagingProvider>);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { ConversationList } from '../src/messaging/ConversationList';
|
|
18
|
+
import { applyFrame, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
19
|
+
|
|
20
|
+
function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
|
|
21
|
+
|
|
22
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
23
|
+
|
|
24
|
+
describe('last-message preview and conversation reorder', () => {
|
|
25
|
+
it('moves a conversation to the top and updates its preview from a server-shaped conversation_upsert frame', async () => {
|
|
26
|
+
const api = {
|
|
27
|
+
listConversations: vi.fn().mockResolvedValue({
|
|
28
|
+
results: [
|
|
29
|
+
{ id: 1, title: 'Older', last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Old preview' } },
|
|
30
|
+
{ id: 2, title: 'Newer', last_message_at: '2026-07-31T10:00:00Z', last_message: { excerpt: 'Newer preview' } },
|
|
31
|
+
],
|
|
32
|
+
next_cursor: null,
|
|
33
|
+
}),
|
|
34
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
35
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
36
|
+
};
|
|
37
|
+
render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
|
|
38
|
+
await screen.findByText('Newer preview');
|
|
39
|
+
// Server-shaped conversation_upsert payload: serialize_conversation_core's
|
|
40
|
+
// real keys (id, last_message_at, last_message: {excerpt}), not a
|
|
41
|
+
// client-invented shape.
|
|
42
|
+
dispatchMessagingFrame({
|
|
43
|
+
envelope: 'messaging', type: 'conversation_upsert', event_id: 'evt-1', conversation_id: 1,
|
|
44
|
+
id: '1', last_message_at: '2026-07-31T12:00:00Z', last_message: { excerpt: 'Fresh reply' },
|
|
45
|
+
});
|
|
46
|
+
await screen.findByText('Fresh reply');
|
|
47
|
+
const rows = screen.getAllByRole('button').map((el) => el.textContent);
|
|
48
|
+
const olderIndex = rows.findIndex((text) => text.includes('Older'));
|
|
49
|
+
const newerIndex = rows.findIndex((text) => text.includes('Newer'));
|
|
50
|
+
expect(olderIndex).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(olderIndex).toBeLessThan(newerIndex);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reflects an edited or deleted last message through a REST refresh', async () => {
|
|
55
|
+
const api = {
|
|
56
|
+
listConversations: vi.fn()
|
|
57
|
+
.mockResolvedValueOnce({ results: [{ id: 1, title: 'Chat', last_message: { excerpt: 'Original text' } }], next_cursor: null })
|
|
58
|
+
.mockResolvedValueOnce({ results: [{ id: 1, title: 'Chat', last_message: { excerpt: '' } }], next_cursor: null }),
|
|
59
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
60
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
61
|
+
};
|
|
62
|
+
function Refresher() {
|
|
63
|
+
const { refreshConversations } = useMessaging();
|
|
64
|
+
return <button onClick={() => refreshConversations()}>refresh</button>;
|
|
65
|
+
}
|
|
66
|
+
render(<MessagingProvider api={api}><ConversationList /><Refresher /></MessagingProvider>);
|
|
67
|
+
await screen.findByText('Original text');
|
|
68
|
+
screen.getByText('refresh').click();
|
|
69
|
+
await waitFor(() => expect(screen.queryByText('Original text')).toBeNull());
|
|
70
|
+
await screen.findByText('MessagingList.NO_MESSAGES');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('applies a real message_edited frame (dcm always attaches frame.message) and updates the edited body', () => {
|
|
74
|
+
const state = { ...cache(), messages: { 'msg-1': { id: 'msg-1', conversation_id: 'conv-1', body: 'Before' } } };
|
|
75
|
+
// dcm's publish_messaging_event (realtime.py) always attaches a full
|
|
76
|
+
// frame.message = serialize_message(message) for message_edited, never
|
|
77
|
+
// just {message_id} alone — verified against django_core_micha's actual
|
|
78
|
+
// realtime.py. This is the real wire shape, not a client-invented one.
|
|
79
|
+
const next = applyFrame(state, {
|
|
80
|
+
envelope: 'messaging', type: 'message_edited', event_id: 'evt-2', conversation_id: 'conv-1', message_id: 'msg-1',
|
|
81
|
+
message: { id: 'msg-1', conversation_id: 'conv-1', body: 'After edit', edited_at: '2026-07-31T12:00:00Z' },
|
|
82
|
+
}, null);
|
|
83
|
+
expect(next.messages['msg-1'].body).toBe('After edit');
|
|
84
|
+
expect(next.messages['msg-1'].edited_at).toBe('2026-07-31T12:00:00Z');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keys a message_edited frame by message_id defensively, even if a producer ever omitted frame.message', () => {
|
|
88
|
+
const state = { ...cache(), messages: { 'msg-1': { id: 'msg-1', conversation_id: 'conv-1', body: 'Before' } } };
|
|
89
|
+
const next = applyFrame(state, { envelope: 'messaging', type: 'message_edited', event_id: 'evt-3', conversation_id: 'conv-1', message_id: 'msg-1' }, null);
|
|
90
|
+
expect(next.messages['msg-1']).toBeTruthy();
|
|
91
|
+
expect(next.messages['conv-1']).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : key }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
18
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
19
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
20
|
+
|
|
21
|
+
function baseApi(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
24
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
25
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A genuinely server-shaped serialize_poll payload (django_core_micha
|
|
31
|
+
// serializers.py:52-64): id/question/allow_multiple/closed_at/created_by_id,
|
|
32
|
+
// options carry {id,text,order,vote_count,voters} — never selected/voted
|
|
33
|
+
// flags, never created_by as an object. voted_option_ids is added ONLY on
|
|
34
|
+
// the vote/create/close REST responses (views.py _poll_response), never here.
|
|
35
|
+
function serverPoll(overrides = {}) {
|
|
36
|
+
return {
|
|
37
|
+
id: 'poll-1', question: 'Where should we meet?', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
|
|
38
|
+
options: [
|
|
39
|
+
{ id: 'opt-a', text: 'Library', order: 0, vote_count: 2, voters: ['user-1', 'user-2'] },
|
|
40
|
+
{ id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] },
|
|
41
|
+
],
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
46
|
+
// PollCard is a "dumb" prop-driven component (matches Thread.jsx's usage,
|
|
47
|
+
// which passes down cache.messages[id] directly) — it does not subscribe to
|
|
48
|
+
// the cache itself. A live-frame test must read the message back out of the
|
|
49
|
+
// cache, not hold a static prop, or it can never observe a frame update.
|
|
50
|
+
function ConnectedPollCard({ messageId }) {
|
|
51
|
+
const { cache } = useMessaging();
|
|
52
|
+
return <PollCard message={cache.messages[messageId]} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
56
|
+
|
|
57
|
+
describe('poll rendering against the real server contract', () => {
|
|
58
|
+
it('renders question, options, vote counts and per-option voters from a server-shaped payload', () => {
|
|
59
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
60
|
+
expect(screen.getByText('Where should we meet?')).toBeTruthy();
|
|
61
|
+
expect(screen.getByText(/Library \(2\)/)).toBeTruthy();
|
|
62
|
+
expect(screen.getByText(/Cafe \(1\)/)).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('derives selection from voted_option_ids (REST-only) and marks the viewer\'s own vote', () => {
|
|
66
|
+
const poll = serverPoll({ voted_option_ids: ['opt-a'] });
|
|
67
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
68
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
69
|
+
expect(checkboxes[0].checked).toBe(true);
|
|
70
|
+
expect(checkboxes[1].checked).toBe(false);
|
|
71
|
+
expect(screen.getByText(/Library.*MessagingPoll\.YOU_VOTED/)).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not clear or invert the viewer\'s own vote when a poll_updated frame carries no voted_option_ids', async () => {
|
|
75
|
+
const api = baseApi();
|
|
76
|
+
const votedPoll = serverPoll({ voted_option_ids: ['opt-a'] });
|
|
77
|
+
render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /><State /></MessagingProvider>);
|
|
78
|
+
// Seed the cache the way a real poll message arrives: a `message` frame
|
|
79
|
+
// (kind: 'poll') carrying the just-voted poll, exactly as this viewer's
|
|
80
|
+
// own castPollVote reconciliation would leave it — not a static prop the
|
|
81
|
+
// component can never observe a later frame update against.
|
|
82
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: votedPoll } });
|
|
83
|
+
await waitFor(() => expect(screen.getAllByRole('checkbox')[0].checked).toBe(true));
|
|
84
|
+
// The real poll_updated frame shape: payload.poll is a bare serialize_poll
|
|
85
|
+
// result (verified against services.py's _poll_updated_payload) — never
|
|
86
|
+
// carries voted_option_ids. A vote_count bump from another viewer must
|
|
87
|
+
// not touch this viewer's own selection.
|
|
88
|
+
dispatchMessagingFrame({
|
|
89
|
+
envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
|
|
90
|
+
poll: { ...serverPoll(), options: [{ id: 'opt-a', text: 'Library', order: 0, vote_count: 3, voters: ['user-1', 'user-2', 'user-4'] }, { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] }] },
|
|
91
|
+
});
|
|
92
|
+
await waitFor(() => expect(screen.getByText(/Library \(3\)/)).toBeTruthy());
|
|
93
|
+
// The frame carries no voted_option_ids at all — the viewer's REST-known
|
|
94
|
+
// vote must be carried forward, not wiped, by this update.
|
|
95
|
+
expect(screen.getAllByRole('checkbox')[0].checked).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('grants close only to the poll creator (created_by_id) or a host override, never a guess from an object shape', () => {
|
|
99
|
+
const poll = serverPoll({ created_by_id: 'user-9' });
|
|
100
|
+
const { rerender } = render(<AuthContext.Provider value={{ user: { id: 'user-9' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
101
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
|
|
102
|
+
rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
103
|
+
expect(screen.queryByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeNull();
|
|
104
|
+
rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard canClose message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
105
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('disables voting once the poll is closed', () => {
|
|
109
|
+
const poll = serverPoll({ closed_at: '2026-07-31T10:00:00Z' });
|
|
110
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
111
|
+
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox.disabled).toBe(true));
|
|
112
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.VOTE' }).disabled).toBe(true);
|
|
113
|
+
expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.emoji ? `${key}:${options.emoji}:${options.count}` : key }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { ReactionBar } from '../src/messaging/ReactionBar';
|
|
18
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
19
|
+
|
|
20
|
+
function baseApi(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
24
|
+
addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// ReactionBar is prop-driven (matches every other messaging leaf component) —
|
|
29
|
+
// a live-frame test must read the message back out of the cache.
|
|
30
|
+
function ConnectedReactionBar({ messageId }) {
|
|
31
|
+
const { cache } = useMessaging();
|
|
32
|
+
return <ReactionBar message={cache.messages[messageId]} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
36
|
+
|
|
37
|
+
describe('reaction frame/REST merge against the real aggregate-only contract', () => {
|
|
38
|
+
it('an unrelated reaction frame updates its own emoji without touching the viewer\'s known reacted state on other emoji', async () => {
|
|
39
|
+
const api = baseApi();
|
|
40
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
41
|
+
// Seed: the viewer has already reacted with 👍 (locally known — e.g. from
|
|
42
|
+
// an earlier REST confirmation), the server aggregate never carries that.
|
|
43
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1, reacted: true }] } });
|
|
44
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' })).toBeTruthy());
|
|
45
|
+
// A different viewer reacts with 🎉 — dcm's real reaction frame shape
|
|
46
|
+
// (serialize_reactions): {emoji, count} only, never reacted, for the
|
|
47
|
+
// WHOLE aggregate list including 👍.
|
|
48
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 1 }, { emoji: '🎉', count: 1 }] });
|
|
49
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:🎉:1' })).toBeTruthy());
|
|
50
|
+
const thumbsUp = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
|
|
51
|
+
expect(thumbsUp.className.includes('MuiChip-colorPrimary')).toBe(true); // still marked as the viewer's own reaction
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
|
|
55
|
+
const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }) });
|
|
56
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
57
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
|
|
58
|
+
await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
|
|
59
|
+
fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
|
|
60
|
+
fireEvent.click(screen.getByText('👍'));
|
|
61
|
+
await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', '👍'));
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
|
|
64
|
+
expect(chip.className.includes('MuiChip-colorPrimary')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a duplicate event_id changes nothing', async () => {
|
|
69
|
+
const api = baseApi();
|
|
70
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
71
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1 }] } });
|
|
72
|
+
await waitFor(() => screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' }));
|
|
73
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 2 }] });
|
|
74
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy());
|
|
75
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 99 }] });
|
|
76
|
+
expect(screen.queryByRole('button', { name: 'MessagingReactions.TOGGLE:👍:99' })).toBeNull();
|
|
77
|
+
expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# MSG-3c — deliver the rows MSG-3b left blocked against a stale dcm version
|
|
2
|
+
|
|
3
|
+
Status: planned · Tier 2 · Target repo: `ui-core-micha` (main)
|
|
4
|
+
**Binding spec:** `django-core-micha/docs/design/messaging-platform.md`. Extends the landed MSG-3b
|
|
5
|
+
(published ucm 2.17.0), same convention as `MSG-2b` / `MSG-2c` / `NOTIF-8b`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Part A — Envelope (Expertenchat, 2026-07-31)
|
|
10
|
+
|
|
11
|
+
### Goal
|
|
12
|
+
|
|
13
|
+
Deliver the seven checklist rows MSG-3b correctly refused to fake — and which became buildable while
|
|
14
|
+
MSG-3b was still running, without the session ever learning.
|
|
15
|
+
|
|
16
|
+
### Why this WO exists
|
|
17
|
+
|
|
18
|
+
MSG-3b started against dcm **2.36.1** and pinned that version explicitly in every BLOCKED entry of
|
|
19
|
+
`docs/messaging-deviations.md`. dcm **2.37.0** (MSG-2c) landed mid-flight and delivered exactly what
|
|
20
|
+
those entries said was missing. The WO closed — six chunks, per-chunk reviews, a clean WO-end
|
|
21
|
+
`ui_reviewer` row-by-row cross-check — with seven rows recorded as blocked on "a future dcm work order"
|
|
22
|
+
that had in fact already shipped.
|
|
23
|
+
|
|
24
|
+
The WO-end review did not catch it because it cross-checked the checklist against **ucm's** landed code
|
|
25
|
+
and took the dependency's state from the WO text. That is the third occurrence of one pattern in this
|
|
26
|
+
workstream: a side verified against itself, with the seam assumed rather than read. See §5 of
|
|
27
|
+
`notifications-messaging-roadmap.md`.
|
|
28
|
+
|
|
29
|
+
**Credit where it is due, and the reason this was cheap to find:** MSG-3b wrote the dcm version into
|
|
30
|
+
every BLOCKED entry. Without that pin the staleness would have been invisible instead of a two-minute
|
|
31
|
+
check. Keep doing it — this WO makes it a rule.
|
|
32
|
+
|
|
33
|
+
### Verified as deliverable against dcm 2.37.0
|
|
34
|
+
|
|
35
|
+
Checked in `django-core-micha` at 2.37.0 before this WO was written:
|
|
36
|
+
|
|
37
|
+
| Row | What MSG-3b recorded | Actual state in 2.37.0 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 38 | "dcm does not serialize `last_message`" | `serialize_last_message` exists; `last_message` is on the conversation payload |
|
|
40
|
+
| 51 | "no poll read serialization" | `serialize_poll` exists, embedded in `serialize_message` for `kind == "poll"` |
|
|
41
|
+
| 52 | "per-option voter names absent" | `voters` is in the poll projection (user ids, every conversation kind) |
|
|
42
|
+
| 53 | "closed state depends on the absent poll contract" | `closed_at` is in the projection |
|
|
43
|
+
| 56 | "dcm 2.36.1 does not emit `reaction`" | emitted (MSG-2c wired 8 frames) |
|
|
44
|
+
| 57 | "dcm 2.36.1 does not emit `poll_updated`" | emitted |
|
|
45
|
+
| 58 | "reorder/preview need the missing `last_message`" | available per row 38 |
|
|
46
|
+
|
|
47
|
+
### Expected outcome
|
|
48
|
+
|
|
49
|
+
The seven rows above delivered per the MSG-3b checklist's original descriptions:
|
|
50
|
+
|
|
51
|
+
- **38 / 58** — real last-message preview in `ConversationList`, and list reorder plus preview refresh
|
|
52
|
+
on new / edited / deleted messages.
|
|
53
|
+
- **51-53** — `PollCard` renders question, options, per-option `vote_count` and `voters`, closed state
|
|
54
|
+
with voting disabled. Read `voted_option_ids` **only** from the three poll REST responses; it is
|
|
55
|
+
deliberately absent from `serialize_poll` and from every realtime frame, so do not look for it there
|
|
56
|
+
and do not reconstruct it from a frame.
|
|
57
|
+
- **56 / 57** — the existing `reaction` and `poll_updated` handlers stop being dead code; verify they
|
|
58
|
+
merge correctly now that frames actually arrive, rather than assuming the untested paths are right.
|
|
59
|
+
- `docs/messaging-deviations.md` corrected: these seven entries move from BLOCKED to delivered, and the
|
|
60
|
+
closing paragraph stops claiming they need a future dcm WO.
|
|
61
|
+
|
|
62
|
+
**Still legitimately BLOCKED, do not touch:** rows **27** (no readable thread reply state) and **42**
|
|
63
|
+
(`external_key` not serialized). Both are real dcm gaps that MSG-2c did not cover; both are addressed by
|
|
64
|
+
dcm `MSG-2d`, and a later ucm WO delivers them. MSG-3b assessed these two correctly.
|
|
65
|
+
|
|
66
|
+
### The rule this WO adds
|
|
67
|
+
|
|
68
|
+
**A BLOCKED row is re-verified against the live dependency at WO close, not at WO start.** Concretely:
|
|
69
|
+
|
|
70
|
+
1. Every BLOCKED entry in `docs/messaging-deviations.md` must name the dependency version it was
|
|
71
|
+
blocked at — MSG-3b already did this; make it explicit policy.
|
|
72
|
+
2. Extend the contract-conformance test from MSG-3b chunk 1: it must fail when a BLOCKED entry carries
|
|
73
|
+
no version pin. A test cannot check whether dcm has since shipped — it has no access to it — but it
|
|
74
|
+
can guarantee the pin exists, which is what makes the manual re-check a two-minute job instead of an
|
|
75
|
+
archaeology exercise.
|
|
76
|
+
3. The WO-end review instruction changes: cross-check BLOCKED rows against the **dependency repo at its
|
|
77
|
+
current version**, not against the WO text.
|
|
78
|
+
|
|
79
|
+
### Non-goals / do-not-touch
|
|
80
|
+
|
|
81
|
+
Rows 27 and 42 (blocked on dcm `MSG-2d`). Any dcm change. Any app-side code. The jg migration (MSG-5).
|
|
82
|
+
Search, typing, client→server WS. The decomposition and component boundaries established in MSG-3 and
|
|
83
|
+
held through MSG-3b — new rendering lands in the component that owns it, and the ~400 LOC soft trigger
|
|
84
|
+
and one-harness-entry-per-component rule stay in force. Do not re-open rows already delivered or
|
|
85
|
+
already recorded as deliberate deviations.
|
|
86
|
+
|
|
87
|
+
### Required tests to WRITE
|
|
88
|
+
|
|
89
|
+
- **Poll rendering** against a server-shaped payload — the projection as `serialize_poll` actually emits
|
|
90
|
+
it, not a client-constructed object. This is the MSG-3b lesson repeated: the threading tests passed
|
|
91
|
+
for a year of nobody noticing precisely because they used client-shaped fixtures.
|
|
92
|
+
- **`voted_option_ids` comes only from REST:** a `poll_updated` frame carrying no such key must not
|
|
93
|
+
clear or invert the viewer's own vote state.
|
|
94
|
+
- **`reaction` / `poll_updated` frame merge:** an arriving frame updates the rendered message for a
|
|
95
|
+
viewer who did not cause it; a duplicate `event_id` changes nothing.
|
|
96
|
+
- **Last-message preview and reorder:** a new message moves its conversation to the top and updates the
|
|
97
|
+
preview; an edit updates it; a delete empties it.
|
|
98
|
+
- **Conformance test extension:** a BLOCKED entry without a version pin fails the build — assert the
|
|
99
|
+
negative case, not only the positive.
|
|
100
|
+
- Existing ucm suites stay green.
|
|
101
|
+
|
|
102
|
+
### Risks
|
|
103
|
+
|
|
104
|
+
- **The dead handlers were never exercised.** Rows 56/57 are the paths most likely to contain latent
|
|
105
|
+
bugs, because they have never run against a real frame. Treat them as new code under test, not as
|
|
106
|
+
already-working code being unblocked.
|
|
107
|
+
- **Fixture shape** — see the poll test above; the single most repeated failure in this workstream is a
|
|
108
|
+
test that passes because it invented its own payload.
|
|
109
|
+
- Deviation-doc drift: correcting seven entries by hand while leaving two correct ones intact is exactly
|
|
110
|
+
where an over-eager edit removes a real blocker.
|
|
111
|
+
|
|
112
|
+
### Preconditions
|
|
113
|
+
|
|
114
|
+
dcm 2.37.0 published (met). Approval Gate #1 = operator go on this envelope. Rows 27/42 additionally
|
|
115
|
+
require dcm `MSG-2d`, which is **not** a precondition for this WO.
|
|
116
|
+
|
|
117
|
+
### Release
|
|
118
|
+
|
|
119
|
+
One version bump + npm publish at WO end.
|
|
120
|
+
|
|
121
|
+
### Execution directive
|
|
122
|
+
|
|
123
|
+
Implement through `codex exec` in the background — invoked **directly via Bash** (never the
|
|
124
|
+
`debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check` and
|
|
125
|
+
`--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from a file;
|
|
126
|
+
fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
127
|
+
|
|
128
|
+
### Mini-handover (pastable)
|
|
129
|
+
|
|
130
|
+
Orchestrator: implement `work-orders/MSG-3c.md` in `ui-core-micha` (main). `git pull` first, read the WO
|
|
131
|
+
+ `django-core-micha/docs/design/messaging-platform.md`, then follow `orchestrate-codex` (Codex-first,
|
|
132
|
+
own review per chunk, `ui_reviewer` at WO end, one publish at WO end). Rows 27 and 42 stay BLOCKED —
|
|
133
|
+
they wait on dcm `MSG-2d`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Part B — Implementation map (Orchestrator)
|
|
138
|
+
|
|
139
|
+
**Target repo working directory (all chunks):** `C:\Users\biglmi\Documents\webapps\ui-core-micha` (repo
|
|
140
|
+
root). **Chunk plan (Orchestrator's own — the envelope named rows, not chunks):**
|
|
141
|
+
|
|
142
|
+
1. Last-message preview + reorder (rows 38, 58).
|
|
143
|
+
2. Poll rendering (rows 51-53).
|
|
144
|
+
3. Reaction/poll_updated frame-merge fixes (row 56/57 verification) + the conformance-test extension +
|
|
145
|
+
the deviation-doc rewrite for all seven rows.
|
|
146
|
+
|
|
147
|
+
**Verified against dcm 2.37.0 while scoping** (do not re-derive these, they're settled): `serialize_last_message`/
|
|
148
|
+
`serialize_conversation_core` (`django-core-micha/src/django_core_micha/messaging/serializers.py:89-113`),
|
|
149
|
+
`serialize_poll`/`serialize_reactions` (`serializers.py:41-46,52-64`), `_poll_response` — the ONLY place
|
|
150
|
+
`voted_option_ids` is added, view-level, on top of `serialize_poll` (`views.py:32-36`, used by the
|
|
151
|
+
create/vote/close poll responses only — never by `serialize_message`'s embedded poll, never by the
|
|
152
|
+
`poll_updated` frame), and the three `conversation_upsert` publish sites (`services.py:95,120,165` —
|
|
153
|
+
**new-message send only**; `edit_message`/`soft_delete_message` publish `message_edited`/`message_deleted`
|
|
154
|
+
but never `conversation_upsert`, so a background conversation's list preview cannot live-update on an
|
|
155
|
+
edit/delete of its last message without a dcm change — out of scope, verify via REST refresh instead,
|
|
156
|
+
per chunk 1 below, do not invent a frame dcm doesn't send).
|
|
157
|
+
|
|
158
|
+
**Two additional bugs found during this scoping, in files this WO already touches — fix them here rather
|
|
159
|
+
than filing separately:**
|
|
160
|
+
- `MessagingProvider.jsx:144` (`applyFrame`'s `message_edited` case): `mergeById(state.messages, payload)`
|
|
161
|
+
where `payload` (no `.message` sub-key on this frame — it only ever carries `message_id`) falls through
|
|
162
|
+
to the raw `frame` object, and `idOf()` (`MessagingProvider.jsx:45`, `item?.id ?? item?.conversation_id
|
|
163
|
+
?? item?.message_id ?? item?.poll_id`) checks `.conversation_id` **before** `.message_id` — every frame
|
|
164
|
+
envelope carries `conversation_id`, so a `message_edited` frame gets keyed into the cache by the
|
|
165
|
+
conversation's id instead of the message's id, silently creating a bogus cache entry and never touching
|
|
166
|
+
the real message. Zero test coverage anywhere in this repo caught it. Fix in chunk 1 (same file as the
|
|
167
|
+
conversation-preview work): merge with an explicit `id: frame.message_id`, not the `idOf` fallback chain.
|
|
168
|
+
- `MessagingProvider.jsx:315-330` (`toggleReaction`) and the `reaction` frame case (`:145-149`): `serialize_reactions`
|
|
169
|
+
(verified above) returns `[{emoji, count}]` **only, never a per-viewer `reacted` flag, ever** — not in
|
|
170
|
+
REST responses, not in frames, by design (aggregate-only). `toggleReaction`'s optimistic update
|
|
171
|
+
correctly sets `reacted: true/false` locally on the user's own click, but then the REST-confirm step
|
|
172
|
+
(`:327`, `reactions: result?.reactions || ...`) and the frame handler (`:148`, `payload.reactions ||
|
|
173
|
+
frame.reactions || ...`) both prefer the server's `reacted`-less array over the optimistic one — so
|
|
174
|
+
confirming the user's own toggle, or literally any other viewer's reaction frame arriving for the same
|
|
175
|
+
message, silently wipes every emoji's locally-known `reacted` state. This is exactly what row 56 asks
|
|
176
|
+
to be verified rather than assumed. Fix in chunk 3 (bundled with the frame-merge verification, same
|
|
177
|
+
underlying fix in both places): merge the server's `{emoji,count}` list with previously-known `reacted`
|
|
178
|
+
flags by emoji key, never wholesale-replace.
|
|
179
|
+
|
|
180
|
+
### Chunk 1 — last-message preview and conversation reorder (rows 38, 58)
|
|
181
|
+
|
|
182
|
+
**Files:** `src/messaging/MessagingProvider.jsx` (the `message_edited` bug fix above; verify — do not
|
|
183
|
+
rebuild — the existing `conversation_upsert` handling at `:160` and `EMPTY_CACHE`/reducer shape),
|
|
184
|
+
`src/messaging/ConversationList.jsx` (verify — likely no change needed, see below), new tests.
|
|
185
|
+
|
|
186
|
+
**Much of rows 38/58 turns out to already be built.** MSG-3b wrote `applyFrame`'s `conversation_upsert`
|
|
187
|
+
case and `ConversationList`'s `last_message?.body` rendering + `last_message_at` sort defensively,
|
|
188
|
+
against the design doc's frame contract, before dcm actually sent the data. Now that dcm 2.37.0 does
|
|
189
|
+
(`conversation_upsert` on new-message send carries the real `serialize_conversation_core`, including
|
|
190
|
+
`last_message`), this is primarily a **verification + test-writing chunk**, not a rebuild — confirm this
|
|
191
|
+
by reading `ConversationList.jsx:11` (`ordered()`, sorts by `last_message_at`) and `:71`
|
|
192
|
+
(`conversation.last_message?.body`) before writing anything new. If you find either needs a real change
|
|
193
|
+
beyond the `message_edited` fix above, make it — but don't assume a rewrite is needed without checking.
|
|
194
|
+
|
|
195
|
+
**Row 58's "an edit updates it; a delete empties it"** must be tested via **REST refresh**
|
|
196
|
+
(`refreshConversations()`), not a live frame — dcm does not publish `conversation_upsert` on edit/delete
|
|
197
|
+
(see the verified call-site list above). `serialize_last_message` (`serializers.py:89-97`) already
|
|
198
|
+
returns `excerpt: ""` for a deleted last message — that's the server behavior a refresh test should
|
|
199
|
+
assert against, mocked with a server-shaped `listConversations` response, not invented.
|
|
200
|
+
|
|
201
|
+
**Required tests (chunk 1):**
|
|
202
|
+
- A `conversation_upsert` frame with a real `last_message` moves that conversation to the top of
|
|
203
|
+
`ConversationList` and updates its preview text — server-shaped fixture (`last_message: {body, ...}`,
|
|
204
|
+
not a client-invented shape).
|
|
205
|
+
- A REST refresh reflecting an edited last message updates the preview; reflecting a deleted one
|
|
206
|
+
(`last_message.excerpt: ""` — check the actual field name your fixture should use, verify against
|
|
207
|
+
`serialize_last_message`'s real key, which is `excerpt` not `body`, at the `last_message` level —
|
|
208
|
+
don't assume the `Conversation.last_message` shape matches `Message`'s own `body` field name) shows
|
|
209
|
+
the empty/no-preview state.
|
|
210
|
+
- The `message_edited` frame-keying regression: a `message_edited` frame with only `{message_id,
|
|
211
|
+
conversation_id}` (the real minimal shape — verified above, it carries no other fields) must patch the
|
|
212
|
+
message keyed by `message_id`, and must NOT create/touch any cache entry keyed by `conversation_id`.
|
|
213
|
+
- Existing ucm suites stay green.
|
|
214
|
+
|
|
215
|
+
**Progress contract / preamble:** identical to MSG-3b's chunks — `PLAN:`/`PROGRESS:`/`RESULT:` lines, no
|
|
216
|
+
git operations, write-and-run-only-the-new-tests, leave the diff uncommitted for the orchestrator's
|
|
217
|
+
review.
|
|
218
|
+
|
|
219
|
+
**Mini-handover:** repo `C:\Users\biglmi\Documents\webapps\ui-core-micha`, branch `main`, WO
|
|
220
|
+
`work-orders/MSG-3c.md` chunk 1 (Part B above). Follow `orchestrate-codex`.
|
|
221
|
+
|
|
222
|
+
**Note on chunk 1's actual execution:** Codex reported `workspace is out of credits` on invocation
|
|
223
|
+
(not a transient rate limit) — the orchestrator implemented chunk 1 directly per the Codex→Claude
|
|
224
|
+
fallback rule, and an independent reviewer ran (mandatory once the orchestrator is the author). Chunks
|
|
225
|
+
2/3 below should still attempt Codex first per the execution directive, but expect the same
|
|
226
|
+
out-of-credits failure and fall back the same way if it recurs — don't loop retrying it.
|
|
227
|
+
|
|
228
|
+
### Chunk 2 — poll rendering (rows 51-53)
|
|
229
|
+
|
|
230
|
+
**Files:** `src/messaging/PollCard.jsx` (all three rows land here), new tests. `PollCard.jsx` was built
|
|
231
|
+
in MSG-3 speculatively, before dcm's poll read contract existed — it guesses at field names, and two of
|
|
232
|
+
those guesses are confirmed wrong against the real server shape.
|
|
233
|
+
|
|
234
|
+
**Verified against dcm 2.37.0 (`django-core-micha/src/django_core_micha/messaging/serializers.py:52-64`,
|
|
235
|
+
`views.py:32-36`):** `serialize_poll` returns `{id, question, allow_multiple, closed_at, created_by_id,
|
|
236
|
+
options: [{id, text, order, vote_count, voters}]}` — `voters` is an array of user ids (row 52).
|
|
237
|
+
`voted_option_ids` is added **only** by `_poll_response` (`views.py:32-36`), a view-level enrichment
|
|
238
|
+
used **only** by the create/vote/close poll REST responses — it is never in `serialize_poll` itself,
|
|
239
|
+
never in `serialize_message`'s embedded poll (message list/thread loads), and never in the
|
|
240
|
+
`poll_updated` realtime frame. This is the WO's explicit "read `voted_option_ids` only from the three
|
|
241
|
+
poll REST responses" instruction, now with the exact citations.
|
|
242
|
+
|
|
243
|
+
**Two confirmed bugs in `PollCard.jsx` against this real shape — fix both:**
|
|
244
|
+
1. `PollCard.jsx:18` — `const creatorId = poll.created_by?.id ?? poll.created_by;` assumes a
|
|
245
|
+
`created_by` object or scalar; the real field is `created_by_id` (a plain id, verified above).
|
|
246
|
+
`poll.created_by` is always `undefined` against the real server, so `creatorId` is always
|
|
247
|
+
`undefined`, so `canClose`'s only non-host-supplied fallback (`creatorId === currentUser?.id`,
|
|
248
|
+
`:19`) can never be true for a genuine creator — close is effectively broken for every poll unless a
|
|
249
|
+
host explicitly passes `canClose`. Fix: read `poll.created_by_id` directly.
|
|
250
|
+
2. `PollCard.jsx:15` — `useEffect(() => setSelected((poll?.options || []).filter((option) =>
|
|
251
|
+
option.selected || option.voted)...` assumes per-option `selected`/`voted` flags that don't exist
|
|
252
|
+
anywhere in the real projection (verified above: options only ever have `{id, text, order,
|
|
253
|
+
vote_count, voters}`). Selection must instead derive from `poll.voted_option_ids` **when present**
|
|
254
|
+
(only true right after a create/vote/close REST call — the WO's explicit constraint) and otherwise
|
|
255
|
+
default to empty (a poll loaded via `listMessages`/`listThread`, or updated via a `poll_updated`
|
|
256
|
+
frame, correctly has no way to know the current viewer's vote state from that payload alone — do not
|
|
257
|
+
guess, do not carry over stale local state across a payload that structurally cannot contain the
|
|
258
|
+
answer).
|
|
259
|
+
|
|
260
|
+
**Row 52 (voters) — not rendered at all today:** add voter identification per option. dcm only gives
|
|
261
|
+
user **ids** (`option.voters: [user_id, ...]`), never names — there is no user-directory endpoint
|
|
262
|
+
(same constraint already established for the DM candidate list in `MSG-3b`). Render what's actually
|
|
263
|
+
available: a count (`voters.length`) is always correct; showing the *current viewer's own* vote as
|
|
264
|
+
"you voted for this" is derivable from `voted_option_ids` after a REST action, or otherwise left
|
|
265
|
+
unmarked. Do not invent a name lookup — this is a real, structural limit of the dcm contract, not
|
|
266
|
+
something to work around client-side. If host-supplied user resolution is later wanted, that is a
|
|
267
|
+
future WO's host-prop pattern, not this chunk's job.
|
|
268
|
+
|
|
269
|
+
**Row 53 (closed state, voting disabled)** — `PollCard.jsx:17` (`const closed =
|
|
270
|
+
Boolean(poll.closed_at)`) is already correct; verify the disabled-while-closed wiring on the vote
|
|
271
|
+
controls (`:32`) still works once selection is correctly derived from `voted_option_ids`, and add
|
|
272
|
+
coverage — this row was previously untested against a real payload shape too.
|
|
273
|
+
|
|
274
|
+
**Required tests (chunk 2):**
|
|
275
|
+
- Poll rendering against a genuinely server-shaped `serialize_poll` payload (all the real keys above,
|
|
276
|
+
not a client-invented one) — question, options, `vote_count`, some indication of `voters`.
|
|
277
|
+
- `voted_option_ids` present only in the vote/create/close REST response correctly drives selection
|
|
278
|
+
state; a `poll_updated` frame carrying no `voted_option_ids` key must not clear or invert the
|
|
279
|
+
viewer's own already-known vote state (this is the WO's explicitly named required test).
|
|
280
|
+
- Close permission: `created_by_id === currentUser.id` correctly enables the close control; a
|
|
281
|
+
non-creator without a host-supplied `canClose` override does not get it.
|
|
282
|
+
- Closed poll disables voting.
|
|
283
|
+
- Existing ucm suites (chunk 1 inclusive) stay green.
|
|
284
|
+
|
|
285
|
+
**Progress contract / preamble:** identical to chunk 1's — `PLAN:`/`PROGRESS:`/`RESULT:` lines, no git
|
|
286
|
+
operations, write-and-run-only-the-new-tests, leave the diff uncommitted for the orchestrator's review.
|
|
287
|
+
|
|
288
|
+
**Mini-handover:** repo `C:\Users\biglmi\Documents\webapps\ui-core-micha`, branch `main`, WO
|
|
289
|
+
`work-orders/MSG-3c.md` chunk 2 (Part B above, after chunk 1 is committed). Follow `orchestrate-codex`.
|
|
290
|
+
|
|
291
|
+
**Note from chunk 2:** the same defect class row 56/57 ask to be verified for (a `poll_updated`/
|
|
292
|
+
`reaction` frame's viewer-independent projection silently wiping viewer-specific state the client
|
|
293
|
+
learned from a REST response) was ALSO found and fixed in `poll_updated`'s handler while writing
|
|
294
|
+
chunk 2's own tests (`MessagingProvider.jsx`'s `poll_updated` case now preserves a previously-known
|
|
295
|
+
`voted_option_ids` when the incoming frame lacks it). Chunk 3 must check whether the `reaction` case
|
|
296
|
+
has the analogous issue — it very likely does (see below), don't assume it's fine because it's a
|
|
297
|
+
similar shape to the code that turned out to be wrong.
|
|
298
|
+
|
|
299
|
+
### Chunk 3 — reaction frame-merge fix, conformance-test extension, deviation-doc rewrite (rows 56, 57 + the WO's new rule)
|
|
300
|
+
|
|
301
|
+
**Files:** `src/messaging/MessagingProvider.jsx` (`applyFrame`'s `reaction` case, and `toggleReaction`'s
|
|
302
|
+
REST-confirm path — same underlying bug, two call sites), `tests/messagingContractConformance.test.js`
|
|
303
|
+
(the version-pin extension), `docs/messaging-deviations.md` (full rewrite of the seven now-delivered
|
|
304
|
+
rows + the two still-BLOCKED rows, each BLOCKED entry now carrying an explicit version pin), new tests.
|
|
305
|
+
|
|
306
|
+
**The reaction bug — verified, not hypothetical:** `django-core-micha/src/django_core_micha/messaging/serializers.py:41-46`
|
|
307
|
+
(`serialize_reactions`) returns `[{emoji, count}]` **only, never a per-viewer `reacted` flag, in any
|
|
308
|
+
context** — not REST, not frames, by design (the design doc's "aggregate only" rule). `MessagingProvider.jsx`'s
|
|
309
|
+
`toggleReaction` (around `:315-330`) correctly sets `reacted: true/false` optimistically on the current
|
|
310
|
+
user's own click, but then:
|
|
311
|
+
- its own REST-confirm step merges `reactions: result?.reactions || result?.message?.reactions ||
|
|
312
|
+
nextReactions` — `result.reactions` (the real server array) is truthy and picked first, wiping the
|
|
313
|
+
optimistic `reacted` flags for every emoji on that message.
|
|
314
|
+
- `applyFrame`'s `reaction` case (`:154` area) merges `reactions: payload.reactions || frame.reactions
|
|
315
|
+
|| previous.reactions || []` — same problem: any `reaction` frame arriving for this message (the
|
|
316
|
+
user's own echoed toggle, or literally any other viewer reacting with a different emoji) wholesale-
|
|
317
|
+
replaces the reactions array with the server's `reacted`-less version, wiping every previously-known
|
|
318
|
+
`reacted` flag on that message, not just the one that changed.
|
|
319
|
+
|
|
320
|
+
**Fix both call sites the same way:** merge the server's `{emoji,count}` list with previously-known
|
|
321
|
+
`reacted` flags by emoji key — never wholesale-replace. For each emoji in the incoming server list, if
|
|
322
|
+
a previous entry for that same emoji had `reacted: true`, carry it forward (unless this merge IS the
|
|
323
|
+
direct result of the current user's own toggle action, in which case the just-computed optimistic value
|
|
324
|
+
is correct and already known — don't let the REST-confirm step regress what the optimistic update just
|
|
325
|
+
set). An emoji present in the previous list but absent from the incoming one means its count hit zero
|
|
326
|
+
and it should simply not appear (matches the existing pattern at `:320`, `.filter((reaction) =>
|
|
327
|
+
reaction.count !== 0)`).
|
|
328
|
+
|
|
329
|
+
**Required tests (chunk 3, reaction part):**
|
|
330
|
+
- A `reaction` frame for an emoji the viewer has NOT reacted with updates its count without touching
|
|
331
|
+
the viewer's `reacted` state on any OTHER emoji on the same message (the WO's explicitly named "an
|
|
332
|
+
arriving frame updates the rendered message for a viewer who did not cause it" case).
|
|
333
|
+
- The viewer's own optimistically-set `reacted: true` on one emoji survives (a) the REST confirmation
|
|
334
|
+
of that same toggle, and (b) an unrelated `reaction` frame for a different emoji on the same message
|
|
335
|
+
arriving afterward.
|
|
336
|
+
- A duplicate `event_id` changes nothing (verify the existing `seenEventsRef` de-dup in the provider's
|
|
337
|
+
`subscribe(...)` handler already covers this at the reducer-dispatch level — this may already be
|
|
338
|
+
correct and just need a test, not a code change; check before assuming otherwise).
|
|
339
|
+
|
|
340
|
+
**Conformance-test extension (the WO's new rule, required, not optional):** add a check to
|
|
341
|
+
`tests/messagingContractConformance.test.js` that reads `docs/messaging-deviations.md` and fails if any
|
|
342
|
+
line containing `**BLOCKED**` does not also carry an explicit dependency-version pin (e.g. a regex for
|
|
343
|
+
`dcm \d+\.\d+\.\d+` on that line or within its paragraph — check the actual final wording you land on
|
|
344
|
+
in the doc rewrite below and match the regex to it, don't write the regex first and hope the prose
|
|
345
|
+
matches). **Verified: this check does not currently exist, and would currently fail** — as of this
|
|
346
|
+
WO's start, rows 27/38/42/51/52/53/58's `**BLOCKED**` lines in `docs/messaging-deviations.md` do NOT
|
|
347
|
+
carry a version pin (only the doc's intro sentence and rows 56/57 happen to mention "dcm 2.36.1"); only
|
|
348
|
+
rewriting the doc's BLOCKED rows to each name the version fixes this. **Required negative-case test**
|
|
349
|
+
(the WO is explicit: "assert the negative case, not only the positive"): construct a small in-memory
|
|
350
|
+
BLOCKED line missing a version pin and assert the check's own logic flags it — don't only test that the
|
|
351
|
+
real file passes, prove the check can fail.
|
|
352
|
+
|
|
353
|
+
**`docs/messaging-deviations.md` rewrite — the WO's central deliverable for this chunk:**
|
|
354
|
+
- Rows 38, 51, 52, 53, 56, 57, 58 move from `BLOCKED` to their real delivered outcome (`OK`, matching
|
|
355
|
+
the pattern MSG-3b's own doc used for delivered rows) — each entry should reflect what was actually
|
|
356
|
+
built across chunks 1-3 of MSG-3c (last-message preview/reorder, poll rendering incl. the
|
|
357
|
+
`created_by_id`/`voted_option_ids` fixes, the `reaction`/`poll_updated` frame-merge fixes), not a
|
|
358
|
+
generic "delivered" note copy-pasted seven times.
|
|
359
|
+
- Rows **27 and 42 stay BLOCKED** — do not touch their substance, only add the now-required version pin
|
|
360
|
+
if missing (e.g. "as of dcm 2.37.0" or whatever phrasing matches the new conformance-test regex).
|
|
361
|
+
These are real, still-open gaps (thread-receipt read state; `external_key` serialization) waiting on
|
|
362
|
+
a future dcm `MSG-2d`.
|
|
363
|
+
- The closing "Required backend follow-up" paragraph (currently lists rows 27/38/42/51-53/56-58 as all
|
|
364
|
+
needing a future dcm WO) must be corrected to name only rows 27 and 42.
|
|
365
|
+
- **Risk named in the WO, read before editing:** "correcting seven entries by hand while leaving two
|
|
366
|
+
correct ones intact is exactly where an over-eager edit removes a real blocker" — do not touch rows
|
|
367
|
+
27/42's substantive claims, only add the version pin to them if the doc rewrite's new house style
|
|
368
|
+
requires it project-wide.
|
|
369
|
+
|
|
370
|
+
**Progress contract / preamble:** identical to prior chunks — `PLAN:`/`PROGRESS:`/`RESULT:` lines, no
|
|
371
|
+
git operations, write-and-run-only-the-new-tests, leave the diff uncommitted for the orchestrator's
|
|
372
|
+
review.
|
|
373
|
+
|
|
374
|
+
**Mini-handover:** repo `C:\Users\biglmi\Documents\webapps\ui-core-micha`, branch `main`, WO
|
|
375
|
+
`work-orders/MSG-3c.md` chunk 3 (Part B above, after chunk 2 is committed) — the WO's last content
|
|
376
|
+
chunk. After this chunk's independent review and commit, run the WO-end `ui_reviewer` pass per the
|
|
377
|
+
WO's changed instruction (cross-check BLOCKED rows against the dependency repo at its **current**
|
|
378
|
+
version, not against the WO text), then one version bump + npm publish.
|