@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.
- package/WORK_ORDERS.md +4 -2
- package/dev/entries.jsx +17 -2
- package/dist/i18n/messagingTranslations.js +1 -0
- package/dist/messaging/ConversationList.js +15 -3
- package/dist/messaging/DirectMessageLauncher.js +5 -2
- package/dist/messaging/MessagingProvider.js +37 -2
- package/dist/messaging/Thread.js +21 -2
- package/docs/messaging-deviations.md +10 -10
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +1 -0
- package/src/messaging/ConversationList.jsx +13 -3
- package/src/messaging/DirectMessageLauncher.jsx +7 -2
- package/src/messaging/MessagingProvider.jsx +35 -2
- package/src/messaging/Thread.jsx +19 -2
- package/tests/messagingDirectMessageLauncher.test.jsx +41 -0
- package/tests/messagingThreadReceipts.test.jsx +170 -0
- package/tests/messagingUnreadLifecycle.test.jsx +4 -1
- package/work-orders/DX-1.md +4 -1
- package/work-orders/MSG-3d.md +268 -0
- package/work-orders/MSG-3e.md +164 -0
|
@@ -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
|
-
|
|
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
|
|
package/work-orders/DX-1.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# DX-1 — minimal Vite dev harness for ucm components
|
|
2
2
|
|
|
3
|
-
Status:
|
|
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.
|