@micha.bigler/ui-core-micha 2.18.0 → 2.20.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.
@@ -55,6 +55,47 @@ describe('DirectMessageLauncher', () => {
55
55
  await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
56
56
  });
57
57
 
58
+ it('uses the selected candidate scope over the launcher scope', () => {
59
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
60
+ renderLauncher({
61
+ api,
62
+ candidates: [
63
+ { id: 2, display_name: 'Alex', scope: 'scope-a' },
64
+ { id: 3, display_name: 'Sam', scope: 'scope-b' },
65
+ ],
66
+ scope: 'scope-prop',
67
+ });
68
+ openPicker();
69
+ fireEvent.click(screen.getByText('Sam'));
70
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
71
+ expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 3, scope: 'scope-b' });
72
+ });
73
+
74
+ it('falls back to the launcher scope when the selected candidate has none', () => {
75
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
76
+ renderLauncher({
77
+ api,
78
+ candidates: [
79
+ { id: 2, display_name: 'Alex' },
80
+ { id: 3, display_name: 'Sam', scope: 'scope-b' },
81
+ ],
82
+ scope: 'scope-prop',
83
+ });
84
+ openPicker();
85
+ fireEvent.click(screen.getByText('Alex'));
86
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
87
+ expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 2, scope: 'scope-prop' });
88
+ });
89
+
90
+ it('omits scope when neither the candidate nor launcher provides one', () => {
91
+ const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
92
+ renderLauncher({ api });
93
+ openPicker();
94
+ fireEvent.click(screen.getByText('Alex'));
95
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
96
+ expect(Object.keys(api.createDirectConversation.mock.calls[0][0])).toEqual(['target_user_id']);
97
+ });
98
+
58
99
  it('surfaces a policy rejection without closing the picker', async () => {
59
100
  const api = { createDirectConversation: vi.fn().mockRejectedValue({ response: { data: { detail: 'Direct messages are restricted.' } } }) };
60
101
  renderLauncher({ api });
@@ -0,0 +1,170 @@
1
+ // @vitest-environment jsdom
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import React from 'react';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
7
+
8
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count !== undefined ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
9
+
10
+ const realtimeSubscribers = new Map();
11
+ vi.mock('../src/notifications/realtime', () => ({
12
+ useRealtime: () => ({
13
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
14
+ onReconnect: () => () => {},
15
+ }),
16
+ }));
17
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
18
+
19
+ import { ConversationList } from '../src/messaging/ConversationList';
20
+ import { Thread } from '../src/messaging/Thread';
21
+ import { applyFrame, MessagingProvider } from '../src/messaging/MessagingProvider';
22
+
23
+ // Server-shaped serialize_message output (django_core_micha/messaging/
24
+ // serializers.py:80-95): reply_count/last_reply_at always present;
25
+ // thread_last_read_at is REST-only and never on a frame — copied from the
26
+ // real _message_response/_message_page_response wiring in views.py, not
27
+ // hand-invented.
28
+ function serverRoot(overrides = {}) {
29
+ return { id: 'root-1', conversation_id: 'conv-1', body: 'Root', sender_id: 'user-1', kind: 'chat', reply_count: 1, last_reply_at: '2026-08-01T09:00:00Z', thread_last_read_at: null, ...overrides };
30
+ }
31
+ function serverReply(overrides = {}) {
32
+ return { id: 'reply-1', conversation_id: 'conv-1', body: 'Reply', sender_id: 'user-2', kind: 'chat', reply_to_id: 'root-1', reply_count: 0, last_reply_at: null, created_at: '2026-08-01T10:00:00Z', ...overrides };
33
+ }
34
+ function baseApi(overrides = {}) {
35
+ return {
36
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
37
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
38
+ markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn(),
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
44
+
45
+ describe('the merge trap — thread_last_read_at must survive frames that never carry it', () => {
46
+ function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
47
+
48
+ it('a server-shaped message frame does not wipe a previously-fetched thread_last_read_at on an unrelated root', () => {
49
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ thread_last_read_at: '2026-08-01T08:00:00Z' }) } };
50
+ // A brand-new, unrelated root message frame — server-shaped, no
51
+ // thread_last_read_at key, exactly as serialize_message emits it.
52
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message', event_id: 'evt-1', conversation_id: 'conv-1', message: serverRoot({ id: 'root-2', thread_last_read_at: undefined, reply_to_id: undefined }) }, 'conv-1');
53
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T08:00:00Z');
54
+ });
55
+
56
+ it('a server-shaped message_edited frame does not wipe the edited root\'s own thread_last_read_at', () => {
57
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ thread_last_read_at: '2026-08-01T08:00:00Z', body: 'Before' }) } };
58
+ const edited = serverRoot({ body: 'After', thread_last_read_at: undefined });
59
+ delete edited.thread_last_read_at;
60
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message_edited', event_id: 'evt-2', conversation_id: 'conv-1', message_id: 'root-1', message: edited }, null);
61
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T08:00:00Z');
62
+ expect(next.messages['root-1'].body).toBe('After');
63
+ });
64
+
65
+ it('a reply frame bumping a cached root\'s reply_count/last_reply_at does not wipe that root\'s thread_last_read_at — the WO\'s highest-risk path', () => {
66
+ // The receipt is deliberately NEWER than the incoming reply, so
67
+ // hasUnreadReplies-style logic would already read "read" before this
68
+ // frame — if the bump wiped thread_last_read_at to null/undefined instead
69
+ // of preserving it, that derived read-state would flip, which this
70
+ // assertion catches directly (unlike a marker-visibility assertion,
71
+ // which can't distinguish "correctly still read" from "field wiped to
72
+ // null" — both render no marker).
73
+ const state = { ...cache(), messages: { 'root-1': serverRoot({ reply_count: 1, last_reply_at: '2026-08-01T08:00:00Z', thread_last_read_at: '2026-08-01T09:00:00Z' }) } };
74
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message', event_id: 'evt-4', conversation_id: 'conv-1', message: serverReply({ id: 'reply-2', created_at: '2026-08-01T10:00:00Z' }) }, 'conv-1');
75
+ expect(next.messages['root-1'].reply_count).toBe(2);
76
+ expect(next.messages['root-1'].last_reply_at).toBe('2026-08-01T10:00:00Z');
77
+ expect(next.messages['root-1'].thread_last_read_at).toBe('2026-08-01T09:00:00Z');
78
+ });
79
+ });
80
+
81
+ describe('unread-reply marker logic', () => {
82
+ it('shows when last_reply_at is newer than the receipt', async () => {
83
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ last_reply_at: '2026-08-01T10:00:00Z', thread_last_read_at: '2026-08-01T09:00:00Z' })] }) });
84
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
85
+ await screen.findByText('Root');
86
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
87
+ });
88
+
89
+ it('hides when the receipt is equal to or newer than last_reply_at', async () => {
90
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ last_reply_at: '2026-08-01T09:00:00Z', thread_last_read_at: '2026-08-01T10:00:00Z' })] }) });
91
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
92
+ await screen.findByText('Root');
93
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
94
+ });
95
+
96
+ it('shows when the receipt is null and reply_count > 0', async () => {
97
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ thread_last_read_at: null, reply_count: 2, last_reply_at: '2026-08-01T09:00:00Z' })] }) });
98
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
99
+ await screen.findByText('Root');
100
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
101
+ });
102
+
103
+ it('renders no toggle and no marker at all when reply_count is 0', async () => {
104
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ reply_count: 0, last_reply_at: null, thread_last_read_at: null })] }) });
105
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
106
+ await screen.findByText('Root');
107
+ expect(screen.queryByText(/MessagingThread\.SHOW_REPLIES/)).toBeNull();
108
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe('opening a thread marks it read from the REST response', () => {
113
+ it('issues the REST read call and updates the cached receipt from last_read_at', async () => {
114
+ const api = baseApi({
115
+ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ thread_last_read_at: null, reply_count: 1, last_reply_at: '2026-08-01T09:00:00Z' })] }),
116
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
117
+ markThreadRead: vi.fn().mockResolvedValue({ last_read_at: '2026-08-01T12:00:00Z' }),
118
+ });
119
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
120
+ await screen.findByText('Root');
121
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
122
+ fireEvent.click(screen.getByText(/MessagingThread\.SHOW_REPLIES/));
123
+ await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith('root-1', undefined));
124
+ await waitFor(() => expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull());
125
+ });
126
+ });
127
+
128
+ describe('live root update on an incoming reply', () => {
129
+ it('raises reply_count/last_reply_at on the cached root and the marker appears without a refetch when the sender is not the viewer', async () => {
130
+ const api = baseApi({ listMessages: vi.fn().mockResolvedValue({ results: [serverRoot({ reply_count: 1, last_reply_at: '2026-08-01T08:00:00Z', thread_last_read_at: '2026-08-01T08:30:00Z' })] }) });
131
+ render(<MessagingProvider api={api} activeConversationId="conv-1"><Thread conversationId="conv-1" /></MessagingProvider>);
132
+ await screen.findByText(/MessagingThread\.SHOW_REPLIES:1/);
133
+ expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull();
134
+ // A new reply from someone else — server-shaped message frame with
135
+ // reply_to_id naming the cached root.
136
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'evt-3', conversation_id: 'conv-1', message: serverReply() });
137
+ await waitFor(() => expect(screen.getByText(/MessagingThread\.SHOW_REPLIES:2/)).toBeTruthy());
138
+ expect(screen.getByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeTruthy();
139
+ });
140
+ });
141
+
142
+ describe('managed-conversation identity (row 42)', () => {
143
+ it('renders two managed conversations with different external_key as distinguishable via a host resolver', async () => {
144
+ const api = baseApi({
145
+ listConversations: vi.fn().mockResolvedValue({ results: [
146
+ { id: 'c1', kind: 'managed', external_key: 'all' },
147
+ { id: 'c2', kind: 'managed', external_key: 'team' },
148
+ ] }),
149
+ });
150
+ render(<MessagingProvider api={api}><ConversationList resolveManagedLabel={(conversation) => conversation.external_key === 'all' ? 'Everyone' : 'My team'} /></MessagingProvider>);
151
+ await screen.findByText('Everyone');
152
+ expect(screen.getByText('My team')).toBeTruthy();
153
+ });
154
+
155
+ it('falls back to the existing title behavior when no resolver is supplied', async () => {
156
+ const api = baseApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'managed', external_key: 'all', title: 'Fallback title' }] }) });
157
+ render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
158
+ await screen.findByText('Fallback title');
159
+ });
160
+
161
+ it('no jg-specific vocabulary (event_all, event_team) appears anywhere in src/', () => {
162
+ const root = path.resolve(import.meta.dirname, '..', 'src');
163
+ const files = fs.readdirSync(root, { recursive: true }).filter((file) => typeof file === 'string' && /\.(jsx?|tsx?)$/.test(file));
164
+ const offenders = files.filter((file) => {
165
+ const contents = fs.readFileSync(path.join(root, file), 'utf8');
166
+ return /event_all|event_team/.test(contents);
167
+ });
168
+ expect(offenders).toEqual([]);
169
+ });
170
+ });
@@ -45,7 +45,10 @@ describe('messaging unread lifecycle', () => {
45
45
  it('marks a reply thread read when it expands', async () => {
46
46
  const api = makeApi({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 40, conversation_id: 12, body: 'Root', reply_count: 1 }] }) });
47
47
  render(<MessagingProvider api={api} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>);
48
- fireEvent.click(await screen.findByRole('button', { name: 'MessagingThread.SHOW_REPLIES' }));
48
+ // No thread_last_read_at in this fixture — the unread-reply marker
49
+ // (MSG-3d) correctly treats a null receipt with replies as unread, which
50
+ // changes the button's accessible name to include the marker text.
51
+ fireEvent.click(await screen.findByRole('button', { name: /MessagingThread\.SHOW_REPLIES/ }));
49
52
  await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith(40, undefined));
50
53
  });
51
54
 
@@ -1,6 +1,9 @@
1
1
  # DX-1 — minimal Vite dev harness for ucm components
2
2
 
3
- Status: planned · Tier 1 (dev-only tooling; **adds devDependencies → approval-gated**) · Target repo: `ui-core-micha` (main)
3
+ Status: done · **Tier 2** (dev-only tooling; **adds devDependencies → approval-gated**) · Target repo: `ui-core-micha` (main)
4
+ *Reclassified 2026-08-01: labelled Tier 1 at authoring, but it adds dependencies in a shared-core
5
+ repo — two binding Tier-2 surfaces per AGENTS.md's Tiering gate. A named independent `reviewer` did
6
+ run (one P2 on the provider's own WebSocket), so only the label was wrong.*
4
7
 
5
8
  ---
6
9
 
@@ -0,0 +1,268 @@
1
+ # MSG-3d — the last two blocked rows: unread-reply marker and managed-conversation identity
2
+
3
+ Status: planned · Tier 2 · Target repo: `ui-core-micha` (main)
4
+ **Binding spec:** `django-core-micha/docs/design/messaging-platform.md` — §"Thread reply state and
5
+ managed-conversation identity" and §Realtime's consumer obligation. Extends the landed MSG-3c
6
+ (published ucm 2.18.0), same convention as `MSG-2b` / `MSG-3b` / `MSG-3c`.
7
+
8
+ ---
9
+
10
+ ## Part A — Envelope (Expertenchat, 2026-08-01)
11
+
12
+ ### Goal
13
+
14
+ Deliver checklist rows **27** and **42** — the last two entries still marked BLOCKED in
15
+ `docs/messaging-deviations.md`. dcm 2.38.0 (MSG-2d) shipped the contract they were waiting for, on the
16
+ same day MSG-3c closed. After this WO the deviation doc has **zero** BLOCKED entries.
17
+
18
+ ### Verified deliverable against dcm 2.38.0
19
+
20
+ Checked in `django-core-micha` before this WO was written (`serializers.py`):
21
+
22
+ | Row | Was blocked on | Present in 2.38.0 |
23
+ |---|---|---|
24
+ | 27 | no readable thread reply state | `reply_count` + `last_reply_at` on `serialize_message` (viewer-independent), `thread_last_read_at` added REST-only |
25
+ | 42 | `external_key` not serialized | `external_key` on `serialize_conversation_core` |
26
+
27
+ ### Expected outcome
28
+
29
+ **Row 27 — unread-reply marker on the thread toggle.** Render the marker when
30
+ `last_reply_at > thread_last_read_at`; a `null` receipt with at least one reply counts as unread; no
31
+ replies means no toggle and no marker. `reply_count` gives the toggle its count without expanding the
32
+ thread. Opening a thread marks it read and updates the receipt **from the REST response** to
33
+ `POST messages/{root_id}/thread/read/` — not from a frame, which cannot carry it.
34
+
35
+ **Live root update on an incoming reply.** A new reply arrives as a `message` frame carrying *the
36
+ reply's* projection — its own `reply_count` is 0 and `last_reply_at` null. Nothing in the frame updates
37
+ the **root's** counters, so without explicit handling the marker only appears after the next refetch.
38
+ jg updates the root live (`Thread.jsx:1488-1537`: increments `reply_count` and marks the thread unread
39
+ when the sender is someone else), so parity requires the same here: on a `message` frame whose
40
+ `reply_to_id` names a cached root, increment that root's `reply_count` and advance its `last_reply_at`.
41
+ This was a gap in the first draft of this envelope, surfaced by review — do not leave it to discovery.
42
+
43
+ **Row 42 — distinguish managed conversations.** Surface `external_key` so a host can tell one managed
44
+ conversation from another. **ucm must not know what the values mean.** `event_all` and `event_team` are
45
+ jg's vocabulary, not the platform's; baking them in would put app-specific code in a consumer-agnostic
46
+ package and break the first non-jg adopter. Expose the value and let the host supply label and icon,
47
+ mirroring the existing `groupLaunchers` host-supplied pattern.
48
+
49
+ **Deviation doc.** Rows 27 and 42 move from BLOCKED to delivered. The doc then contains no BLOCKED
50
+ entries at all — confirm MSG-3c's version-pin conformance check still passes on an empty set rather
51
+ than erroring or vacuously "passing" for the wrong reason.
52
+
53
+ ### The trap this WO walks into — read before writing any merge code
54
+
55
+ `thread_last_read_at` is **viewer-specific and REST-only**. `reply_count` and `last_reply_at` ride
56
+ `serialize_message`, which is embedded verbatim into the `message` and `message_edited` frames. So every
57
+ such frame carries a message projection **with** the reply fields and **without** the receipt.
58
+
59
+ That is precisely the defect shape MSG-3c just fixed three times (`poll_updated`'s `voted_option_ids`,
60
+ the `reaction` frame's `reacted` flags, `toggleReaction`'s REST confirm): a viewer-independent
61
+ projection replacing a cached object and silently wiping viewer-specific state. This WO introduces a
62
+ **fourth field of that shape**, arriving immediately after those fixes. Merge field-wise, preserve
63
+ `thread_last_read_at`, never replace the message object wholesale — the contract obligation is now
64
+ written into design §Realtime. It has a mandatory test below.
65
+
66
+ ### Non-goals / do-not-touch
67
+
68
+ Any dcm change. Any app-specific vocabulary in ucm (see row 42). App-side code, the jg migration
69
+ (MSG-5), search, typing, client→server WS. The component boundaries from MSG-3 — the marker belongs to
70
+ whichever component owns the thread toggle, not to a regrown `Thread`; the ~400 LOC soft trigger and
71
+ one-harness-entry-per-component rule stay in force. Do not re-open rows already delivered or already
72
+ recorded as deliberate deviations.
73
+
74
+ ### Required tests to WRITE
75
+
76
+ - **The merge trap (load-bearing):** a `message` and a `message_edited` frame — **server-shaped, copied
77
+ from real `serialize_message` output**, therefore without `thread_last_read_at` — must leave a
78
+ previously-fetched receipt intact. Assert the cached value after the merge, not just that no error
79
+ was thrown.
80
+ - **Marker logic:** shown when `last_reply_at > thread_last_read_at`; hidden when the receipt is equal
81
+ or newer; shown when the receipt is `null` and `reply_count > 0`; absent entirely when
82
+ `reply_count == 0`.
83
+ - **Read on open:** expanding a thread issues the REST read call and updates the receipt from its
84
+ response.
85
+ - **Live root update:** a server-shaped `message` frame carrying a `reply_to_id` for a cached root
86
+ raises that root's `reply_count` and advances `last_reply_at`, and the marker appears without a
87
+ refetch when the sender is not the viewer.
88
+ - **Row 42:** two managed conversations with different `external_key` values are distinguishable through
89
+ whatever the host supplies, and **no jg-specific string appears anywhere in `src/`** — assert that
90
+ directly, it is the constraint most likely to be quietly violated.
91
+ - **Deviation doc / conformance:** zero BLOCKED entries and the version-pin check still meaningful.
92
+ - Existing ucm suites stay green.
93
+
94
+ **Fixture rule, non-negotiable:** every payload in these tests is copied from actual dcm serializer
95
+ output. Two separate defects in this workstream (`reply_to` vs `reply_to_id`, `last_message.body` vs
96
+ `.excerpt`) survived only because hand-written client-shaped fixtures agreed with the client's mistake.
97
+
98
+ ### Risks
99
+
100
+ - **The merge trap**, above — the single most likely way this WO ships a regression, and the reason its
101
+ first test is mandatory rather than nice-to-have.
102
+ - **jg vocabulary leaking into ucm** on row 42; it has its own assertion.
103
+ - Small WO, low complexity — the risk is complacency, not difficulty.
104
+
105
+ ### Reviews
106
+
107
+ **Tier 2, not Tier 1 — corrected on review.** The row count is small but the change lands in
108
+ `MessagingProvider`'s frame-merge code, which is exactly where MSG-3c's Tier-2 pass found three shipped
109
+ bugs, and this WO's own risk section calls its merge trap the most likely way it ships a regression.
110
+ That is review-sensitive work by AGENTS.md's definition; size does not lower the tier.
111
+
112
+ Independent `reviewer` plus **`ui_reviewer`** at WO end: the marker is a visual
113
+ affordance and the harness exists to look at it. The `ui_reviewer` must also confirm the deviation doc
114
+ reaches zero BLOCKED entries honestly — by delivery, not by deletion.
115
+
116
+ ### Preconditions
117
+
118
+ dcm 2.38.0 published (met — verified in source, not assumed). Approval Gate #1 = operator go on this
119
+ envelope.
120
+
121
+ ### Release
122
+
123
+ One version bump + npm publish at WO end. **This closes the post-audit remediation**: with rows 27 and
124
+ 42 delivered, every row of the MSG-3b checklist is either implemented or a recorded deliberate
125
+ deviation, and MSG-5 becomes unblocked.
126
+
127
+ ### Execution directive
128
+
129
+ Implement through `codex exec` in the background — invoked **directly via Bash** (never the
130
+ `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check` and
131
+ `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from a file;
132
+ fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit. Codex has
133
+ been out of workspace credits recently — verify before assuming the fallback, and if the fallback is
134
+ used, the orchestrator becomes author and the independent review is mandatory, not routine.
135
+
136
+ ### Mini-handover (pastable)
137
+
138
+ Orchestrator: implement `work-orders/MSG-3d.md` in `ui-core-micha` (main). `git pull` first, read the WO
139
+ + `django-core-micha/docs/design/messaging-platform.md` (§"Thread reply state and managed-conversation
140
+ identity", §Realtime's consumer obligation), then follow `orchestrate-codex` (Codex-first, own
141
+ independent review, `ui_reviewer` at WO end, one publish at WO end).
142
+
143
+ ---
144
+
145
+ ## Part B — Implementation map (Orchestrator)
146
+
147
+ **Single chunk** (the envelope's own framing: small WO, low complexity — Tier 2 for review rigor, not
148
+ for decomposition). **Target repo working directory:** `C:\Users\biglmi\Documents\webapps\ui-core-micha`
149
+ (repo root).
150
+
151
+ **Verified against dcm 2.38.0 while scoping (do not re-derive):**
152
+ - `serialize_message` (`django-core-micha/src/django_core_micha/messaging/serializers.py:80-95`) ALWAYS
153
+ includes `reply_count`/`last_reply_at` — annotated-queryset fast path when present
154
+ (`_with_reply_count`, `views.py:39-47`, used by `ConversationMessagesView`/`ThreadView`), a live
155
+ aggregate fallback otherwise (`_reply_stats`, `serializers.py:68-77`). Every `message`/`message_edited`
156
+ frame (built from `serialize_message` via `realtime.py`'s `publish_messaging_event`) therefore always
157
+ carries fresh, viewer-independent reply stats for that specific message.
158
+ - `thread_last_read_at` is added **only** by `_message_response`/`_message_page_response`
159
+ (`views.py:50-74`) — REST-only, viewer-specific, from `MessageThreadReceipt`. `_with_reply_count`'s
160
+ list responses (`GET conversations/{id}/messages/`, `GET messages/{root_id}/thread/`) already attach
161
+ it per-row via one bulk query (not N+1) — so a normal `listMessages`/`listThread` load already gives
162
+ roots both their reply stats AND the viewer's own receipt in one response; no extra call needed on
163
+ mount.
164
+ - `POST messages/{root_id}/thread/read/` (`views.py:342-347`, already wired as `markThreadRead` in
165
+ `api.js:41`) returns `{"last_read_at": ...}` — **note the field name differs from the cached message's
166
+ own `thread_last_read_at` key** — the provider action must map `last_read_at` from this response onto
167
+ the root message's `thread_last_read_at` cache field, they are not the same key name.
168
+ - `serialize_conversation_core` (`serializers.py:107-115` area) now includes `external_key` (`null`
169
+ where the kind doesn't use one).
170
+
171
+ **The merge trap — investigated, resolved by existing infrastructure, still needs its mandatory test:**
172
+ `mergeById`/`reconcileMessage` (`MessagingProvider.jsx:45-79`) already do a **field-wise** merge
173
+ (`{...previous, ...incoming}`), and neither the `message` nor `message_edited` `applyFrame` branches
174
+ explicitly re-derive or strip `thread_last_read_at` the way the `reaction`/`poll_updated` branches used
175
+ to explicitly recompute their own fields — so an incoming frame lacking `thread_last_read_at` (it always
176
+ will) does NOT overwrite a previously-cached value through the current code path. This is a genuine
177
+ finding from investigation, not an assumption: **do not skip the mandatory merge-trap test on the
178
+ strength of this finding** — the WO requires it regardless, and any code added by this WO (the live
179
+ root-update below in particular) must preserve this property rather than accidentally break it by
180
+ constructing a replacement object that omits the key.
181
+
182
+ **Row 27 implementation:**
183
+ 1. `MessagingProvider.jsx`'s `markReplyThreadRead` (currently `:262`,
184
+ `const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt),
185
+ [api]);`) fires the REST call and **discards the response** — nothing ever updates the cache, so the
186
+ receipt is never actually stored client-side today. Fix: on success, patch the root message's
187
+ `thread_last_read_at` from the response's `last_read_at` (field-name mapping per above).
188
+ 2. A marker component/affordance on the thread-toggle button (`Thread.jsx`'s existing
189
+ `toggleReplies`/reply-count button, around where `message.reply_count` is already read at `:93-96`
190
+ for the reply-count label) — show it when `message.last_reply_at > message.thread_last_read_at`, OR
191
+ when `message.thread_last_read_at` is `null`/absent and `message.reply_count > 0`. No marker at all
192
+ when `reply_count === 0` (matches the existing "no toggle button at all when there are no replies"
193
+ behavior — don't add a marker to a control that doesn't render). This belongs in `Thread.jsx` (or a
194
+ small collaborator it owns) — not a new top-level component, per the non-goals' "marker belongs to
195
+ whichever component owns the thread toggle."
196
+ 3. **Live root update on an incoming reply** — in `applyFrame`'s `message` case
197
+ (`MessagingProvider.jsx:126-143`): after the existing reply message itself is merged via
198
+ `reconcileMessage`, additionally check `message.reply_to_id` (server field name — reuse the pattern
199
+ already established, this is NOT the client-shaped `reply_to` used only by locally-built optimistic
200
+ rows) against `state.messages` for a cached root. If found, bump that root's `reply_count` by one and
201
+ set `last_reply_at` to the incoming message's `created_at`, via a **field-wise merge that explicitly
202
+ preserves the root's existing `thread_last_read_at`** (the exact discipline the merge-trap section
203
+ above is about — do this by spreading the previous root object and only overriding
204
+ `reply_count`/`last_reply_at`, never by constructing a fresh object from the frame's data). Apply this
205
+ unconditionally (regardless of who sent the reply) — `reply_count`/`last_reply_at` are
206
+ viewer-independent facts; do not add sender-based suppression logic that isn't explicitly required
207
+ (the required tests only ask for the "sender is not the viewer" case, the common path — don't invent
208
+ untested self-send special-casing beyond that).
209
+
210
+ **Row 42 implementation:** `external_key` needs no new plumbing to reach the cache — `mergeById`/REST
211
+ responses pass every field through transparently already. What's missing is a way for a host to render
212
+ it. Add an optional host-supplied resolver prop to `ConversationList.jsx` (mirrors the existing
213
+ `groupLaunchers`/`broadcastLauncher` host-supplied-data pattern already in this file) — e.g.
214
+ `resolveManagedLabel(conversation)` — called instead of the existing `titleOf()` fallback specifically
215
+ when `conversation.kind === 'managed'` and the prop is supplied; falls back to the existing `titleOf()`
216
+ behavior otherwise (unset prop, or non-managed kind). **ucm must not hardcode or reference `event_all`/
217
+ `event_team` or any other jg-specific vocabulary anywhere in `src/`** — the resolver receives the raw
218
+ `conversation` object (including `external_key`) and the host owns all interpretation.
219
+
220
+ **Deviation doc + conformance test:** move rows 27 and 42 from `BLOCKED` to their delivered outcome in
221
+ `docs/messaging-deviations.md` (describe what was actually built, not a generic note, matching the
222
+ established style from MSG-3c's rewrite). This leaves **zero** `BLOCKED` entries — verify
223
+ `tests/messagingContractConformance.test.js`'s version-pin check (added in MSG-3c) still passes
224
+ meaningfully against an empty set (i.e. `unpinnedBlockedLines()` naturally returns `[]` when there are no
225
+ `**BLOCKED**` lines at all — confirm this is actually true of the regex-based approach, not just assumed
226
+ because the test happens to pass; the WO's own text calls out the "passing vacuously for the wrong
227
+ reason" risk explicitly).
228
+
229
+ **Required tests to WRITE (all named explicitly in the WO's own "Required tests" section — treat that
230
+ section as the checklist, not this paraphrase):**
231
+ - The merge-trap test (load-bearing, per the WO: server-shaped `message` and `message_edited` frames,
232
+ copied from real `serialize_message` shape, must leave a previously-fetched
233
+ `thread_last_read_at` intact).
234
+ - Marker visibility logic (shown/hidden/null-receipt/no-replies cases, all four named in the WO).
235
+ - Read-on-open (expanding a thread issues the REST call and updates the receipt from its response).
236
+ - Live root update (server-shaped `message` frame with `reply_to_id` raises the cached root's
237
+ `reply_count`/`last_reply_at`; marker appears without a refetch for the "sender is not the viewer"
238
+ case).
239
+ - Row 42: two managed conversations with different `external_key` render distinguishable labels via a
240
+ host resolver; **a direct assertion that no jg-specific string (`event_all`, `event_team`) appears
241
+ anywhere in `src/`** — grep-based, mirroring the style of the existing contract-conformance test.
242
+ - Deviation-doc/conformance: zero `BLOCKED` entries; the version-pin check's own logic still behaves
243
+ correctly against that empty set (not just "the real file happens to pass").
244
+ - Existing ucm suites stay green.
245
+
246
+ **Fixture rule (WO's own words, binding):** every payload in these tests is copied from actual dcm
247
+ serializer output — this workstream has twice shipped a bug that survived only because a hand-written
248
+ client-shaped fixture agreed with the client's own mistake (`reply_to` vs `reply_to_id`,
249
+ `last_message.body` vs `.excerpt`). Do not hand-invent a payload shape without checking it against the
250
+ serializer citations above first.
251
+
252
+ **Invariants / do-not-break:** don't touch chunks/rows already delivered; keep the ~400 LOC soft trigger
253
+ in mind for `Thread.jsx`/`ConversationList.jsx`/`MessagingProvider.jsx`; no new top-level component for
254
+ the marker (lives in `Thread.jsx` or a small collaborator it owns); no new DX-1 harness entry needed
255
+ unless a genuinely new independently-mountable component is added (it shouldn't be, per the above).
256
+
257
+ **Progress contract / preamble:** `PLAN:`/`PROGRESS:`/`RESULT:` lines, no git operations, write-and-run-
258
+ only-the-new-tests, leave the diff uncommitted for the orchestrator's review. Codex has been out of
259
+ workspace credits recently across the last several WOs in this workstream — verify liveness before
260
+ assuming success; if Codex fails (credits, quota, non-zero exit), the orchestrator implements directly
261
+ per the fallback rule, which makes the orchestrator the author and independent review mandatory
262
+ (not routine) — this has been true for every chunk of MSG-3c and should be expected here too.
263
+
264
+ **Mini-handover:** repo `C:\Users\biglmi\Documents\webapps\ui-core-micha`, branch `main`, WO
265
+ `work-orders/MSG-3d.md` (Part B above, single chunk). Follow `orchestrate-codex`. After the independent
266
+ review and commit, run the WO-end `ui_reviewer` pass (per the WO's "Reviews" section — confirm the
267
+ deviation doc reaches zero BLOCKED entries honestly, by delivery not deletion, and look at the marker
268
+ as a rendered visual affordance via the DX-1 harness), then one version bump + npm publish.