@micha.bigler/ui-core-micha 2.21.2 → 2.22.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.
@@ -0,0 +1,214 @@
1
+ # WORK ORDER MSG-6f (ui-core-micha) — the host title resolver is never called for broadcasts, and the read ticks claim a delivery that does not exist
2
+
3
+ **EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
4
+ addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
5
+ out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
6
+ via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
7
+ and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
8
+ file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
9
+
10
+ Feature-level WO (Expertenchat envelope). Both defects were found by **verifying the landed MSG-6e /
11
+ MSG-7 / MSG-8 / MSG-10 work in a running app** (jg-ferien local, flag on, ucm 2.21.3, dcm 2.39.5,
12
+ 2026-08-03). Both are cases of "the other side shipped its half; ucm still renders the old behaviour".
13
+
14
+ ## TIER
15
+ Tier 2 — shared-core, consumed by every app. Independent `reviewer` mandatory. `ui_reviewer` for
16
+ scope B (it changes what a user believes about message delivery).
17
+
18
+ ## SCOPE
19
+
20
+ **A. `titleOf` never calls the host resolver for a broadcast — jg's fix is dead code.**
21
+
22
+ `ConversationList.jsx` `titleOf()` gates the host callback on kind:
23
+
24
+ ```js
25
+ if (conversation.kind === 'managed' && resolveManagedLabel) {
26
+ const label = resolveManagedLabel(conversation);
27
+ if (label) return label;
28
+ }
29
+ ```
30
+
31
+ jg-ferien MSG-10 correctly taught its `resolveManagedConversationLabel` to handle
32
+ `kind === 'broadcast'` — but ucm never calls it for that kind, so the branch is unreachable from the
33
+ list and the row still falls through to `t('MessagingList.UNTITLED')`.
34
+
35
+ **Verified live after MSG-10 landed:** the same conversation (`kind: "broadcast"`,
36
+ `external_key: "broadcast"`, `title: null`) renders **"Unbenannte Unterhaltung"** in the list and
37
+ **"Event-Ankündigungen"** in the open thread header — the thread header works only because jg's own
38
+ `resolveConversationTitle` calls the resolver directly, with no kind gate.
39
+
40
+ **The fix: stop gating on kind.** Call `resolveManagedLabel` for **every** conversation and let the
41
+ host decide — a `null` return already means "I have no label", which is exactly the delegation contract,
42
+ and the comment directly above `titleOf` already describes the label as host-supplied. Gating on kind
43
+ inside ucm duplicates a decision that belongs to the host and will break again for the next kind.
44
+
45
+ Check `resolveDirectUserName`'s gate (`kind === 'direct' && other_user_id != null`) in the same pass and
46
+ state whether it has the same problem. Do not change it blindly — a direct conversation is the one kind
47
+ where the id-based lookup is genuinely required.
48
+
49
+ **B. The read ticks assert a delivery that the platform does not track.**
50
+
51
+ Operator report, 2026-08-03: *"die Häkchen geben das Gefühl, dass jede Nachricht sofort zugestellt ist,
52
+ auch wenn das Browserfenster geschlossen ist."* Correct, and it is now strictly worse than when it was
53
+ written:
54
+
55
+ - dcm MSG-7 scope C **removed `delivered_count`** from the `read-status` response — verified live, the
56
+ response now carries only `all_read` and `recipient_detail`.
57
+ - `ReadTicks.jsx` still renders
58
+ `t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 })`. With the field
59
+ gone, `undefined || 0` produces the **identical** label as before. Verified live: every own message
60
+ shows `aria-label="Zugestellt an 0"`.
61
+
62
+ So the UI states "Zugestellt an 0" — simultaneously claiming a delivery and reporting that it reached
63
+ nobody — on a single outlined check that reads as WhatsApp's delivered-tick. There is no delivery
64
+ signal behind it at all.
65
+
66
+ **Resolution (operator-directed, 2026-08-03): show a read PROPORTION in group and broadcast, keep a
67
+ two-state tick in DMs.**
68
+
69
+ The operational need in a camp is *"did a relevant share of the group read this?"* — a leader posting
70
+ "bus leaves at 14:00" to forty participants needs a number, not a checkmark. `all_read` is the wrong
71
+ measurement, not a useless feature: in any group it is essentially never true, so a boolean pins the
72
+ indicator to its first state precisely where the information matters most.
73
+
74
+ Operator decision, 2026-08-03:
75
+
76
+ | Conversation kind | Viewer | Display |
77
+ |---|---|---|
78
+ | `direct` | sender | two-state tick — `DoneOutlinedIcon` **light grey** "Gesendet" → `DoneAllOutlinedIcon` **dark blue** "Gelesen" |
79
+ | `group`, `broadcast`, `managed` | team member | a **ratio**: "18/40" (read / members). Numeric, no icon ladder. |
80
+ | `group`, `broadcast`, `managed` | ordinary participant | **nothing** — render no indicator at all |
81
+ | per-recipient detail | team member | unchanged — already correctly gated (see below) |
82
+
83
+ **The contrast between the two DM states must be clearly stronger than today.** Currently
84
+ `color={status.all_read ? 'primary' : 'inherit'}`, and `inherit` inside the meta `Stack` resolves to
85
+ `text.disabled` — the two states are barely distinguishable. Use an explicit light grey and an explicit
86
+ dark blue rather than `inherit`/`primary`, and verify the difference is obvious at the rendered
87
+ `1rem` icon size in both light and dark theme. The icon shape already differs (`Done` vs `DoneAll`), so
88
+ the distinction does not rest on colour alone — keep it that way.
89
+
90
+ **"nothing" for an ordinary participant in a group is deliberate, not an oversight.** A permanently
91
+ grey tick — which is what `all_read` would produce in any real group — is the "looks stuck" failure this
92
+ scope exists to remove. No indicator is better than a misleading one.
93
+
94
+ **No client-side permission logic.** dcm MSG-9 returns the counts only to viewers holding
95
+ `read_receipt_detail`, so the rule is purely shape-driven: *counts present → ratio; else `direct` →
96
+ tick; else → render nothing*. Do not branch on roles or capabilities in ucm.
97
+
98
+ **The server already has the number and throws it away.** `read_status` (`services.py:344-347`) computes
99
+ `all_read` from exactly the queryset that yields the count:
100
+ `participants.filter(last_read_at__gte=message.created_at).count()` — the same shape as the
101
+ `delivered_count` that had no writer. **A `read_count` + `recipient_count` pair is the counter
102
+ `delivered_count` should have been.**
103
+
104
+ Adding them is a `django-core-micha` change, not ucm's: **this scope is blocked on `django-core-micha`
105
+ MSG-9**, which adds `read_count` and `recipient_count` inside the existing `read_receipt_detail`-gated
106
+ block. Keep `all_read` — the DM branch still uses it.
107
+
108
+ Do not derive the ratio client-side by counting `recipient_detail` yourself. It would happen to work
109
+ (both are gated on the same right), but it re-implements a server aggregate in the client and would
110
+ silently produce a wrong denominator the moment dcm's participant filtering changes. Read the counts
111
+ dcm sends, or render nothing.
112
+
113
+ Retire `MessagingReadTicks.DELIVERED` and its `{{count}}` regardless — the new ratio string is a
114
+ different key with different semantics, and reusing "Zugestellt" would carry the false claim forward.
115
+ Also stop reading the now-always-`null` `last_delivered_at` out of `recipient_detail`.
116
+
117
+ **Per-recipient detail stays as it is.** `services.py:340` already gates it on
118
+ `kind != DIRECT and "read_receipt_detail" in rights` (in jg: `MANAGER_RIGHTS`), and DMs never expose it
119
+ even to moderators — a documented design invariant. This WO does not touch that; the popover keeps
120
+ working for managers.
121
+
122
+ **Why not implement delivery tracking as well** (wiring `mark_delivered` from a client ack, technically
123
+ possible since dcm MSG-8 made WS frames arrive): in a browser app "delivered" can only mean "a tab had
124
+ the socket open at that moment". For a recipient who is simply not online it would sit at the first
125
+ state indefinitely — replacing "looks always delivered" with "looks permanently stuck", plus a write per
126
+ received message. It only becomes meaningful with guaranteed device-level push. A read ratio answers the
127
+ real question without that ambiguity. **If the operator wants delivery too, it is a feature WO.**
128
+
129
+ **C. The read signal misses the most attentive reader — fix this or the ratio understates.**
130
+
131
+ What `read_count` actually measures must be stated plainly, because it is weaker than "read":
132
+ `mark_read` (`dcm services.py:288-294`) sets `ConversationParticipant.last_read_at` to *now*, and after
133
+ MSG-6e scope D there is exactly **one** caller — `Thread.jsx:58`, a `useEffect` keyed on
134
+ `[conversationId, markConversationRead]`. So the recorded fact is **"opened this conversation"**, and
135
+ `read_count` counts participants who opened it at some point after the message was posted.
136
+
137
+ It is not tab-open, not socket-connected, not per-message interaction, not viewport visibility.
138
+
139
+ Two asymmetries follow. The first is acceptable, the second is a defect:
140
+
141
+ 1. Opening a conversation marks its **entire** history read in one stroke. Someone who opens it and
142
+ leaves immediately counts as having read a 200-message backlog. This is the universal convention
143
+ (WhatsApp behaves the same) — accept it, do not try to fix it here.
144
+ 2. **A participant already sitting in the open conversation when the message arrives is NOT counted.**
145
+ The effect is keyed on `conversationId`, so it does not re-fire when a new message lands. The person
146
+ who saw it in real time is recorded as not having read it, until they navigate away and back.
147
+
148
+ Asymmetry 2 is newly common and newly consequential: before dcm MSG-8 no frame was ever delivered, so
149
+ no thread ever updated live and this case did not arise. Now it is the *normal* case for anyone
150
+ actually paying attention — and it biases the ratio **downward**, which is the wrong direction for a
151
+ leader deciding whether to chase people about the bus time.
152
+
153
+ **Fix: re-mark read when a new message arrives in the conversation currently open.** Trigger on the
154
+ incoming `message` frame for the active conversation, not on a timer. Guard it: do not fire for the
155
+ viewer's **own** send (dcm already excludes the sender from the fan-out, so no frame arrives — verify
156
+ rather than assume), and do not fire when the thread is mounted but the document is hidden
157
+ (`document.visibilityState`), or a background tab would report reads nobody performed. `mark_read` is
158
+ already idempotent — it only writes when the new timestamp is later (`services.py:291`) — so a
159
+ redundant call is cheap, but a *wrong* one is not.
160
+
161
+ **Known cost, do not silently ignore:** `Thread` mounts `ReadTicks` per own message and each instance
162
+ fires its own `read-status` request — O(n) for a sender with many messages in a thread, which is exactly
163
+ the broadcast-heavy leader this feature serves. A batch endpoint (message ids → counts) is the natural
164
+ answer and belongs in the same dcm WO. Do **not** fold the counts into `serialize_message` instead:
165
+ `views.py:50-54` documents why viewer-specific read state must stay out of it — it would leak into the
166
+ `message`/`message_edited` realtime frames, the same trap `thread_last_read_at` was kept out of.
167
+
168
+ ## NON-GOALS / DO NOT TOUCH
169
+ - Do not reintroduce `delivered_count` on either side.
170
+ - Do not change `all_read`'s semantics or how `read_state` frames update the cached receipt (that is
171
+ MSG-6e scope C, already landed — leave it working).
172
+ - No change to `isOwn`/`senderName`/`canShowReadTicks`; the `sender` object from dcm 2.39.4 fixed those
173
+ and they are **verified working** (all server-loaded messages now render `own` with a tick).
174
+ - Do not touch jg-ferien. Its `resolveManagedConversationLabel` is already correct and must stay as it
175
+ is — scope A is what makes it reachable.
176
+
177
+ ## RISKS
178
+ - **A changes behaviour for every host app**, including any that relies on `resolveManagedLabel` being
179
+ called only for `managed`. A host returning a non-null label for an unexpected kind would newly see
180
+ it win over `conversation.title`. Check the ordering in `titleOf` after the change: a host label
181
+ should still lose to nothing it previously lost to, apart from the intended new case.
182
+ - B changes a user-facing claim. Getting it wrong in the other direction (removing the indicator
183
+ entirely) loses the "did my message go out" affordance — hence two states, not zero.
184
+ - The retired i18n key may be referenced by host apps' tests. Grep before deleting and note any hits.
185
+
186
+ ## REQUIRED TESTS TO WRITE
187
+ Narrow and behavioural. Do NOT run the full suite.
188
+
189
+ 1. **A:** a conversation with `kind: 'broadcast'`, `title: null`, and a host `resolveManagedLabel` that
190
+ returns a string renders **that string**, not `MessagingList.UNTITLED`. This must **fail against
191
+ today's code** — prove it.
192
+ 2. **A:** a host resolver returning `null` still falls through to the existing chain unchanged
193
+ (regression guard for the delegation contract).
194
+ 3. **A:** the existing `managed` / `event_all` / `event_team` behaviour is unchanged.
195
+ 4. **B:** `direct` renders the two-state tick and the two states use visibly different explicit colours (assert the resolved colours differ, not just that a colour prop is set); a `group` WITH counts renders the ratio; a `group` WITHOUT counts renders **no** indicator. Assert no label containing "Zugestellt" is produced anywhere in `ReadTicks`.
196
+ that **no** label containing a `{{count}}`-derived number is produced anywhere in `ReadTicks`.
197
+ 5. **B:** `all_read` still renders the double check in `primary` with the unchanged label.
198
+
199
+ **Non-vacuity:** tests 1 and 4 must fail on the current code. Test 1 is the one that matters — jg
200
+ shipped a fix for this exact symptom that did nothing, because the test asserted on the wrong side of
201
+ the boundary. Assert on the **rendered list row**, not on the resolver function in isolation.
202
+
203
+ ## TEST SCOPE FOR THE GATE (orchestrator)
204
+ `ConversationList` and `ReadTicks` test files plus the messaging i18n tests. Not the full suite.
205
+
206
+ ## TARGET REPO
207
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
208
+ the repo's release flow. jg's pin bump is a separate step; jg needs it to close its own MSG-9
209
+ finding 4/12, which cannot be fixed from jg at all.
210
+
211
+ ## PROGRESS CONTRACT
212
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
213
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
214
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.