Compass Slack-model thread rendering (SEA-1352)
Status: Active
Tracking: SEA-1352. Parent (frozen):
docs/designs/product/compass-0.8-threading-and-session-renderer/design.md.
This record supersedes by citation that record’s dual-render decision (see
Problem); the frozen record itself is never rewritten.
Problem
Section titled “Problem”The threading UI merged in #841 renders a thread’s reply bodies in two places
at once — inline-indented in the main channel stream AND in the side
ThreadPanel — which matches neither Slack nor Discord. Matt ruled: use the
Slack model — the main stream shows only the root message plus a compact
thread-summary affordance; reply bodies live exclusively in the panel.
Where the duplication lives today (all cites re-verified against main):
-
ThreadView(apps/ui/src/components/ChannelView.tsx:204-241) renders the root, a barereplybutton, and then all reply bodies inline. The inline block,ChannelView.tsx:226-238:<Show when={props.thread.replies.length > 0}><div class="thread-replies"><Index each={props.thread.replies}>{(reply) => (<MessageRowmsg={reply()}byId={props.byId}byHandle={props.byHandle}/>)}</Index></div></Show> -
The indent styling is
.thread-replies(app.css:2958-2967): “A thread: root message + indented replies under it.” withmargin-left: 20px; padding-left: 12px; border-left: 2px solid var(--border). -
ChannelViewalso mounts the panel (ChannelView.tsx:371-375:<ThreadPanel channel={chan()} byId={byId()} byHandle={byHandle()} />), which renders the SAME replies again —ThreadPanel.tsx:82-88reusesMessageRowunder its own.thread-repliesdiv, and its doc comment (ThreadPanel.tsx:46-48) states the duplication as intended: “reuses MessageRow and.thread-repliesso a posted reply appears in both the panel and the main stream”.
The dual render was specified deliberately in the frozen SEA-1337 record:
design.md:110-112 (“ThreadView … renders root + replies under
.thread-replies”) and design.md:372-374 (“composer posts a reply that
appears in-panel AND indented in the stream”). This record supersedes exactly
that decision; everything else in the frozen record stands.
Approach
Section titled “Approach”Slack model. Per thread, the main stream (.conv-stream) renders the root
message plus ONE compact affordance; reply bodies render only inside
ThreadPanel.
The affordance shape
Section titled “The affordance shape”ThreadView (ChannelView.tsx:204-241) changes as follows:
- Removed: the inline
<Show>/.thread-repliesblock (ChannelView.tsx:226-238). ReplyMessageRows never render in the main stream again. - Added: a
.thread-summarybutton, rendered only whenprops.thread.replies.length > 0, sitting as a direct child of.threadimmediately after.thread-root(exactly where the inline block sat — fork 3, a clear call: the summary reads as “this root has a conversation under it”, so it belongs under the root row inside the thread group). It shows, Slack-style:- reply count —
"1 reply"/"N replies"; - participants — the distinct reply authors as placeholder-initial
avatar badges, in first-reply order: each badge shows the handle’s first
character uppercased (
handleOf(comms.ts:24-29) — the codebase’s one resolution convention, so the initial can’t drift fromMessageRow’s.msg-role), carrying the full@handleintitle/aria-label. The pile is capped at 5 badges; a+Noverflow node follows when there are more than 5 participants (Slack’s faces-pile shape). Real photo faces stay deferred until an avatar pipeline exists —.thread-summary-peopleis the drop-in seam; - last-reply time —
hhmm(lastReplyAtUnixMs)via the existing deterministic-UTC formatter (ChannelView.tsx:26-31), rendered as"last HH:MM".
- reply count —
- Click:
store.openThread(props.thread.root.id)— the identical call the current barereplybutton makes (ChannelView.tsx:218-224), driving the unchanged T-T1 store API (store.ts:325-331:openThreadRootId/openThread/closeThread;postReplybelow them). - Zero-reply roots (fork 2, a clear call): the existing
.thread-reply“reply” button (ChannelView.tsx:218-224) is KEPT but gated toreplies.length === 0. A thread must stay startable — dropping the button outright would regress thread creation, and rendering it alongside the summary would be redundant (both callopenThreadon the same root). So each root row carries exactly one affordance:replywhen the thread is empty,.thread-summaryonce it has replies.
The count/participants/last-time derivation is extracted into a pure, unit-
tested helper threadSummary in comms.ts beside Thread/threadsOf
(comms.ts:153-156, comms.ts:195-219) — see Plan T-1. It derives from the
existing Message fields only (comms-stub.ts:154-168: id, channelId,
authorAccountId, atUnixMs, parentMessageId?, blocks); no model change.
- New
.thread-summaryrules inapp.css(compact pill/row: small font,var(--text-faint)base, accent on hover — visually descended from the existing.thread-replyrule atapp.css:3020-3033). .thread-replies(app.css:2958-2967) stays —ThreadPanel.tsx:83still uses it inside the panel, where the indent is correct..thread-reply(app.css:3020-3033) stays — still used by the zero-reply start-a-thread button.
What is explicitly untouched
Section titled “What is explicitly untouched”ThreadPanel.tsx (component, composer, close, membership gating), the T-T1
store API, Thread/threadsOf, Message/fixtures, and all of compass.v1 /
transport / daemon. UI-only, fixture-backed walking skeleton.
Alternatives considered
Section titled “Alternatives considered”- Discord sub-channel model (a thread as a navigable sub-channel with its
own view) — rejected: Matt ruled Slack model for SEA-1352. Also heavier: it
needs routing/navigation state the walking skeleton doesn’t have, vs. reusing
the already-shipped
ThreadPanel+openThreadstore seam unchanged. - Keep the inline dual render (status quo per frozen
design.md:372-374) — rejected: it double-prints every reply, scales the main stream with reply volume (defeating the point of threading), and matches no reference product. The panel already renders replies correctly. - Minimal summary (“N replies” only) — rejected as the resting state:
participants + last-reply time are derived from fields already in hand
(
authorAccountId,atUnixMs) at negligible render cost, and the richer form is the actual Slack shape Matt pointed at. The helper returns all three regardless, so thinning the display later is a markup-only change. - Real photo avatar faces for participants — deferred: the skeleton has no
avatar asset pipeline. Matt ruled placeholder-initial badges (the handle
initial) as the resting state now; real photo faces become a drop-in inside
.thread-summary-peoplewhen an avatar pipeline exists. - Keep the bare
replybutton alongside the summary at N>0 — rejected: two adjacent controls invoking the sameopenThread(root.id)is redundant chrome; the summary itself opens the panel whose composer replies. - Relative last-reply time (“last reply 2h ago”, Slack’s actual copy) —
rejected: relative time needs a wall-clock
now, which the Deterministic-rendering constraint forbids for a fixture-pinned skeleton. Absolutehhmmis the forced, correct choice; a relative form would need an injected clock the skeleton doesn’t have. - Unread / new-reply styling on the summary — deferred, not designed: the
skeleton has no read-state model (no
lastReadAtonChannel/Message,comms-stub.ts:154-168), so there is no substrate. It lands later as an additiveclassListon the same.thread-summarynode once a read model exists — no rework.
Global Constraints
Section titled “Global Constraints”- UI-only. No
compass.v1contract, transport, or daemon change; noparentMessageId/Threadmodel change (comms.ts:153-156,comms-stub.ts:154-168stay as-is). Fixture-backed walking skeleton. ThreadPanelintact (one comment exception).components/ThreadPanel.tsxand its.thread-repliesusage (ThreadPanel.tsx:83) are untouched, EXCEPT the doc comment atThreadPanel.tsx:46-48(“a posted reply appears in both the panel and the main stream”) — which this change falsifies, so T-2 updates that one comment line to describe panel-only replies. Only the main-stream duplication is removed; the.thread-repliesCSS rule stays.- Store API unchanged.
openThread/openThreadRootId/closeThread/postReply(store.ts:325-335) are consumed, never modified. - Red-first (rule://red-green-testing): new assertions written and observed red before the component change.
- Existing thread tests stay green except the two
ThreadPanel.test.tsxlegs this design deliberately changes (audited in T-2 below); those are updated in the same red→green cycle, never deleted. - TypeScript strict;
direnv exec ~/agents/workspaces/franklin/sealed moon run compass-ui:typecheck compass-ui:testgreen; biome-clean. - SolidJS conventions:
ThreadViewstays aComponent; derived values are accessors, not precomputed constants. - Deterministic rendering: times via the existing UTC
hhmm(ChannelView.tsx:26-31); no locale- or wall-clock-dependent output.
T-1 — Pure thread-summary model helper
Section titled “T-1 — Pure thread-summary model helper”Extract the summary derivation into comms.ts, beside Thread / threadsOf
(comms.ts:153-156, 195-219), so the numbers are unit-testable without a
DOM and the component stays markup-only.
Interfaces:
// comms.ts — new, exported
/** Derived stream-facing summary of a thread's replies. */export interface ThreadSummary { /** replies.length. */ replyCount: number; /** Distinct reply authorAccountIds, in first-reply order. */ participantIds: string[]; /** Max reply atUnixMs; 0 when replyCount === 0 (callers render the * summary only when replyCount > 0). */ lastReplyAtUnixMs: number;}
export function threadSummary(thread: Thread): ThreadSummary;Pure and allocation-light: one pass over thread.replies (a Set for
distinctness, running max for the time). No store, no component import.
Test cycle (red → green, in comms.test.ts beside the existing threadsOf
suite at comms.test.ts:80-221):
- zero replies →
{ replyCount: 0, participantIds: [], lastReplyAtUnixMs: 0 }; - N replies, duplicate author (fixture shape:
msg-c2byacc-livingstone,msg-c3byacc-cook, root byacc-cook,comms-stub.ts:320-356) →replyCountcorrect,participantIdsdistinct in first-reply order,lastReplyAtUnixMs= the max replyatUnixMs(out-of-order reply times covered); - root author appears in
participantIdsonly if they also replied.
Run: direnv exec ~/agents/workspaces/franklin/sealed moon run compass-ui:test (red first, then green), then compass-ui:typecheck.
T-2 — ThreadView summary affordance + CSS + test migration
Section titled “T-2 — ThreadView summary affordance + CSS + test migration”Rework ThreadView (ChannelView.tsx:204-241) to the Slack shape, add the
.thread-summary CSS, and migrate the two main-stream assertions in
ThreadPanel.test.tsx — one coherent red→green slice.
Interfaces:
ThreadViewprops are unchanged ({ thread: Thread; byId: Map<string, Account>; byHandle: Map<string, Account> },ChannelView.tsx:204-208); it keeps resolving the store viauseStore().- Markup contract (what tests select):
.thread>.thread-root(rootMessageRow— unchanged);button.thread-reply(“reply”) renders only whenthread.replies.length === 0; click →store.openThread(thread.root.id)(unchanged handler,ChannelView.tsx:221);button.thread-summaryrenders only whenthread.replies.length > 0, as a direct child of.threadafter.thread-root; click →store.openThread(thread.root.id). Inside it:.thread-summary-count(“N replies”, singular “1 reply”),.thread-summary-people(one.thread-summary-avatarbadge perparticipantIdsentry, in order: badge text is the handle’s first char uppercased, resolved viahandleOf(comms.ts:24-29) — the same helperMessageRowuses for.msg-role(ChannelView.tsx:185-187), so the initial can’t drift from the codebase’s one resolution convention — with the full@handleon each badge’stitleattribute (the@is prepended inThreadView—Account.handleis stored bare, sohandleOfreturns e.g.cookand the title is`@${handle}`). The pile is capped at 5 badges fromparticipantIds.slice(0, 5); whenparticipantIds.length > 5a trailing.thread-summary-overflownode renders"+N"(N = participantIds.length - 5). The cap is a view concern only —threadSummarystill returns the fullparticipantIdslist, so no model change),.thread-summary-time(last ${hhmm(lastReplyAtUnixMs)});- the inline
<Show>/.thread-repliesblock (ChannelView.tsx:226-238) is deleted; no replyMessageRowever renders under.conv-stream.
- CSS: new
.thread-summary(+-count/-people/-time/-avatar/-overflow) rules inapp.cssnext to.thread-reply(app.css:3020-3033), same subtle-button vocabulary (var(--text-faint), accent on hover,font-size: 11px,align-self: flex-start); the.thread-summary-avatarbadge is a small square/circle initial chip and.thread-summary-peoplelays the badges in a row..thread-replies(app.css:2958-2967) and.thread-replyrules untouched.
Existing-tests audit (verified in-repo). Enumerated by rendering-surface
class — every test selecting .msg, .conv-stream, or .thread*, not just
by thread-API name (which would miss reply-count assertions using generic
selectors) — so the affected set is store.thread.test.ts,
ThreadPanel.test.tsx, ChannelView.test.tsx,
RightSidebar.fleetpane.test.tsx, and App.test.tsx:
store.thread.test.ts— stays green (all legs): pure store tests, no DOM (store.thread.test.ts:64-128).ChannelView.test.tsx:221-255(“…threads render identically”) — stays green: it compares.thread/.msgcounts between two mounts of the same channel (equality is render-shape-independent) and assertscount(".msg") > 1, which still holds —ch-svc-compassrenders ≥ 2 rootMessageRows (msg-c1atcomms-stub.ts:320andmsg-c4, the zero-reply ask root, atcomms-stub.ts:358).RightSidebar.fleetpane.test.tsx:110-112— stays green: it asserts.conv-stream .msgcount === the channel’s raw message count, the exact shape this change breaks for any channel with a threaded reply — but both fleet home-DMs are flat (the file notes this at :76-77;msg-c2/msg-c3are the fixture’s onlyparentMessageId-bearing messages,comms-stub.ts:336,349), so the count is unchanged. Landmine only if a fixture DM is ever threaded.App.test.tsx:146— stays green: counts.thread(> 0); thread groups persist (only reply bodies leave the stream), so unaffected.ThreadPanel.test.tsx— six of eight legs stay green: “no thread panel by default” (:98), “panel shows root and replies” (:130), “close hides the panel” (:181), both composer-enablement legs (:199, :220), “switching channel removes the panel” (:265) — none touches the main-stream reply render. Two legs change (this design’s intent, migrated red-first):- “reply affordance opens the panel” (:110-124) clicks
.thread-replyon a root that HAS replies (THREADrequirest.replies.length >= 1, :47-64) — under this design that root carries.thread-summaryinstead. Migrate the selector to.thread-summary; sameopenThreadRootId+ panel-appears assertions. - “panel composer posts a reply appearing in-panel and in the stream”
(:146-176) asserts the posted text under
.conv-stream .thread-replies(:170-175) — the exact behavior being removed. Migrate leg (b) to: the posted text does NOT appear under.conv-stream, and the root’s.thread-summary-countreflects the incremented count. Rename the leg title too (e.g. “…appearing in-panel only, not in the stream”) so the name matches the migrated assertion.
- “reply affordance opens the panel” (:110-124) clicks
New red-first assertions (in ThreadPanel.test.tsx or a sibling
ThreadView describe block; written and observed red before the component
edit):
- A thread with N > 0 replies renders
.thread-summaryinside its.thread, its.thread-summary-counttext contains"N replies"(fixture: msg-c1’s 2 replies →"2 replies"), and no reply body renders in the stream:container.querySelectorAll(".conv-stream .thread-replies").length === 0and the reply fixture text (THREAD.replyText) is absent from.conv-stream’s textContent while present in the open panel. - Clicking
.thread-summarysetsstore.openThreadRootId()to the root id and mounts.thread-panel. - A zero-reply root (fixture:
msg-c4, noparentMessageIdchildren) still rendersbutton.thread-reply, and clicking it opens the panel — a thread remains startable. - A root with replies does NOT render
button.thread-reply(exactly one affordance per root). .thread-summary-peoplerenders one.thread-summary-avatarbadge per distinct reply author (badge text = handle initial, full@handleontitle), in first-reply order;.thread-summary-timeshowshhmmof the latest reply. A thread with > 5 distinct participants caps the pile at 5 badges and renders a.thread-summary-overflow"+N"node — the 2-participant stub never overflows, so the cap path needs a purpose-built fixture with ≥ 6 distinct reply authors.
Test cycle: write 1-5 + the two migrations → run
direnv exec ~/agents/workspaces/franklin/sealed moon run compass-ui:test
(observe the new legs red, the six untouched ThreadPanel legs green) →
implement ThreadView + CSS → all green → moon run compass-ui:typecheck →
biome check clean.
- T-1:
threadSummary(thread)helper incomms.ts+ unit tests incomms.test.ts(red → green). - T-2:
ThreadViewSlack-model rework — delete the inline.thread-repliesblock, add gated.thread-summary/.thread-replyaffordances (summary carries placeholder-initial.thread-summary-avatarbadges capped at 5 + a.thread-summary-overflow"+N"node),.thread-summary*CSS, migrate the twoThreadPanel.test.tsxlegs, add assertions 1-5 + the overflow-cap fixture (red → green);moon run compass-ui:typecheck compass-ui:testgreen, biome-clean.
Resolved questions (Matt-ruled)
Section titled “Resolved questions (Matt-ruled)”These were flagged open for Matt at design review; his rulings are folded into the Approach and T-2 above and recorded here for provenance.
- Summary richness → full Slack-shaped summary. Matt ruled the full form
(count + participants + last-reply
hhmm), not the minimal “N replies”. As designed;threadSummaryalready returns all three. - Participant rendering → placeholder-initial avatar badges now. Matt ruled
placeholder avatars over text
@handlechips: each badge shows the handle’s first character uppercased, full@handleontitle. Real photo faces stay deferred until an avatar pipeline exists (.thread-summary-peopleis the seam). - Participant-chip overflow →
+Ncap at 5. Matt ruled the Slack cap now: the pile shows at most 5 badges and a trailing"+N"overflow node. Kept a view concern —threadSummaryreturns the fullparticipantIds, the cap is applied inThreadView, so no model change.