You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two HiveMind PRs (#2097, #2109) landed the full deliberation engine: private asides, cross-desk referral, episode outcome reporting, and a vending-machine bundle that exercises all of it live. What they did not add is a UI that surfaces any of it.
The result today: an operator opening a desk with HiveMind enabled sees a wall of flat chat bubbles — !propose, !evidence, !commit, and aside rows all render identically to ordinary replies. The structure the deliberation engine produced (who spoke, in what role, to whom, with what outcome) is invisible. An operator cannot tell a quorum commit from a stray remark, cannot see that an aside happened, and cannot trace a cross-desk referral back to the answer that resolved it.
This issue tracks three deliverables that together close that gap, reusing what is already in the event log:
Backend projection — extend MessageView with the four HiveMind fields the frontend needs and project them from AgentReply.
Frontend message rendering — render HiveMind moves with distinct visual treatment in the existing MessageRow.tsx / MessageTimeline.tsx.
Desk transcript view — a new read-only route (#/desks/{id}/transcript) that renders a complete episode-grouped deliberation log.
Problem
1 — MessageView projects none of the HiveMind fields
// src/server/chat_history.rs:300-406pubstructMessageView{pubid:String,pubauthor:String,pubtext:String,// no audience, no hive_move, no episode_id, no elided}
And every projection site hard-codes the empty vec rather than reading from the event:
// src/server/chat_history.rs:1206 (and ~18 more sites)
audience:Vec::new(),
So the console never learns an aside was sent, which members it was between, or whether to show the content or an elided stub.
2 — No hive_move tag
A deliberation move marker (!propose, !evidence, !support, !commit, !question, !defer, !aside @peer) is embedded in the reply text and parsed only by tinyhivemind at step time. MessageView exposes the raw text. The frontend would have to re-implement the same prefix grammar to extract the move kind — brittle and already done on the server.
The server should extract the move kind at projection time and expose it as a nullable typed field.
3 — No episode_id — messages from one episode cannot be grouped
An episode's messages are scattered through the desk journal interleaved with whatever else happened. There is no grouping key. The frontend cannot tell where an episode started, where it ended, or which commit row closed it. The HIVE_REPORT_AUTHOR summary (src/hivemind/episode.rs:1043) that closes each episode is journaled as a plain AgentReply — indistinguishable from a normal reply once the agent_id is the only marker.
4 — No elided flag on aside rows
A viewer not in an aside's audience should see a stub ([aside between fleet_tech and route_planner]) rather than content. Today the history read applies no viewer-scoped elision — it returns all content to all viewers (src/server/chat_history.rsproject arm matches AgentReply without filtering on audience). This is documented as correct for operators (docs/spec/runtime/hivemind-asides.md, "An operator and every person reads every aside in full"), so viewer-scoped elision only applies to agent peers. But the console should be able to render the elided stub without re-running the episode.
5 — The frontend has no HiveMind-aware rendering
frontend/src/views/chat/MessageRow.tsx renders every AgentReply identically. There is no:
Visual badge for the move kind (propose / evidence / support / commit / question / defer)
Aside indicator or elided stub
Episode grouping or episode-outcome chip naming the commit and the committer
Cross-desk referral sub-thread linking back to the referring desk
Open issues that this closes or partially addresses: #1956 (first-person-collapsed transcript), #1957 (missed teammate reply after rebind), #1959 (desk_context has no production consumer).
Solution
Layer 1 — MessageView: four new fields
// src/server/chat_history.rs — MessageView additionspubstructMessageView{// ...existing fields unchanged.../// The HiveMind deliberation move kind for this row, when the reply was/// written in the context of a desk episode.////// Extracted from the text prefix at projection time so the frontend does/// not re-implement the move grammar. `None` for ordinary (non-episode)/// replies and for every reply journaled before this field existed.pubhive_move:Option<HiveMoveKind>,/// The episode this row belongs to, identified by the trigger message's/// event sequence (the `OperatorMessage` that opened the episode).////// Carries the same value for every message the episode journals, so the/// frontend can group them without scanning for the HIVE_REPORT_AUTHOR row./// `None` for non-episode replies.pubepisode_id:Option<String>,/// The aside audience for this row, when it is narrower than the full desk.////// Empty = desk-visible (the ordinary case). Non-empty = this row is a/// private aside between the author and these members. The author is not/// repeated in the list.pubaudience:Vec<String>,/// Whether this row's content is elided for the requesting viewer.////// `true` when the row is a private aside and the viewer is not its author/// or in its audience. The `text` field is empty when `elided` is `true`;/// the `audience` field still carries the participants so the frontend can/// render `[aside between fleet_tech and route_planner]`.////// Always `false` for operators and people (they read every aside in full).pubelided:bool,}/// The deliberation move a HiveMind episode member made.#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)]#[serde(rename_all = "snake_case")]pubenumHiveMoveKind{Propose,Evidence,Support,Commit,Question,Defer,Aside,/// Catch-all for a move prefix the projection could not parse.Unknown,}
Projection (project fn in chat_history.rs): when matching CompanyEvent::AgentReply { audience, text, .. }, extract the move kind from the text prefix, pass audience through, compute elided from the viewer's identity vs. the audience list.
Episode id: thread the trigger's EventSeq through EpisodeDriver::run (src/hivemind/episode.rs:211) and journal it on every AgentReply the episode writes (a new episode_id: Option<String> field on AgentReply, additive: #[serde(default, skip_serializing_if = "Option::is_none")]).
Move badge — when hiveMove is set, render a left-border stripe whose color encodes the move kind (propose = blue, evidence = amber, support = green, commit = emerald + bold, question = purple, defer = slate, aside = dashed border). The badge sits left of the author name so the room's deliberation structure reads as a visual rhythm even when text is collapsed.
Aside / elided — when audience.length > 0, render a compact aside between {author} and {audience} label in the header. When elided: true, replace the bubble body with an italicized stub and suppress tool steps.
MessageTimeline.tsx — episode grouping:
Collect consecutive messages sharing the same episodeId into a collapsible <EpisodeBlock>. The block header shows participant avatars, turn count, and an outcome chip ("committed" / "deadlocked" / "exhausted" / "deferred") extracted from the HIVE_REPORT_AUTHOR row that closes the episode. Default to expanded for the most recent episode, collapsed for older ones.
Cross-desk referral sub-threads: a row whose agent_id is HIVE_REFERRAL_AUTHOR renders as a quoted block with an outbound arrow and the referred desk's name; the answer row that follows it is indented under it.
Layer 3 — New route: #/desks/{id}/transcript
A dedicated read-only view, separate from the conversational #/chat/{desk_id} route.
What it is not: it is not a chat. No message composer, no send button, no reply affordance. It is an observer surface — the operator watches what the room produced.
What it shows:
Full episode history for the desk, paginated oldest-first within episodes (reverse chronological across episodes so the most recent work is at the top).
Episode blocks (as above) with expand / collapse controls.
A compact stat row per episode: N turns · M members · outcome · duration.
Aside rows with full content for operators; elided stubs for non-audience peers.
Cross-desk referral branches, indented under the row that triggered the referral.
A "live" indicator when an episode is currently running (the InflightRunBar SSE stream already carries this signal).
Route registration (frontend/src/App.tsx): add /desks/:deskId/transcript beside the existing /chat/:threadId routes.
Navigation entry: the desk's channel header (ChatHeader.tsx) gains a Transcript tab/link that opens the view for the current desk. The link is only rendered when the desk has hive.enabled set in the manifest (or defaults to enabled by having >= 2 members).
Sidebar: the desk's row in ChannelRail.tsx gets a HiveMind indicator icon so the operator can see at a glance which desks are running episodes.
Acceptance criteria
MessageView gains four fields — hive_move: Option<HiveMoveKind>, episode_id: Option<String>, audience: Vec<String>, elided: bool — each additive (#[serde(default)], skip_serializing_if) so no stored record needs migrating and every existing test compiles unchanged.
AgentReply gains episode_id — journaled by EpisodeDriver::run for every message the episode writes; the trigger's event seq (cast to string) is the id; additive on the same terms.
Move extraction is server-side — the prefix grammar (!propose, !evidence, etc.) is parsed once in the project fn in chat_history.rs and never duplicated in the frontend. Unknown prefixes yield HiveMoveKind::Unknown, not an error.
Aside projection is viewer-scoped — a viewer whose id is not in the aside's audience (and is not an operator) receives text: "", elided: true. Operators always receive the full text. The viewer id is the already-resolved by from the auth context.
Move badge renders in MessageRow.tsx — each HiveMoveKind has a distinct left-border color and a text label ("proposes", "supports", "commits", etc.) beside the author. No badge when hiveMove is absent.
Aside UI — a row with audience set renders an aside header label; an elided row renders the stub instead of message content, with no tool-call steps shown.
Episode blocks in MessageTimeline.tsx — messages sharing an episodeId are grouped; the block header shows outcome and stat line; most-recent episode is expanded by default.
#/desks/{id}/transcript route — new read-only view, no composer, paginated episode history, collapsible episode blocks, live indicator, aside rendering, cross-desk referral indentation.
Navigation — ChatHeader.tsx on a HiveMind desk shows a Transcript tab/link; ChannelRail.tsx shows a HiveMind indicator on hive-enabled desks.
Playwright spec: desk-transcript.spec.ts — drives the vending_machine_co bundle (already in companies/): loads #/desks/ops/transcript, asserts episode blocks are present, asserts the commit row has the commit badge, asserts aside rows show the audience label. Uses mock-plan scripting following orchestration-simulation.spec.ts — not gated on LIVE_BRAIN.
Existing tests unchanged — cargo test passes; the four new additive fields on AgentReply / MessageView do not break any deserialization or assertion.
Summary
Two HiveMind PRs (#2097, #2109) landed the full deliberation engine: private asides, cross-desk referral, episode outcome reporting, and a vending-machine bundle that exercises all of it live. What they did not add is a UI that surfaces any of it.
The result today: an operator opening a desk with HiveMind enabled sees a wall of flat chat bubbles —
!propose,!evidence,!commit, and aside rows all render identically to ordinary replies. The structure the deliberation engine produced (who spoke, in what role, to whom, with what outcome) is invisible. An operator cannot tell a quorum commit from a stray remark, cannot see that an aside happened, and cannot trace a cross-desk referral back to the answer that resolved it.This issue tracks three deliverables that together close that gap, reusing what is already in the event log:
MessageViewwith the four HiveMind fields the frontend needs and project them fromAgentReply.MessageRow.tsx/MessageTimeline.tsx.#/desks/{id}/transcript) that renders a complete episode-grouped deliberation log.Problem
1 —
MessageViewprojects none of the HiveMind fieldsAgentReplyinsrc/ports/types.rsalready carries:But
MessageViewinsrc/server/chat_history.rs:And every projection site hard-codes the empty vec rather than reading from the event:
So the console never learns an aside was sent, which members it was between, or whether to show the content or an elided stub.
2 — No
hive_movetagA deliberation move marker (
!propose,!evidence,!support,!commit,!question,!defer,!aside @peer) is embedded in the reply text and parsed only bytinyhivemindat step time.MessageViewexposes the raw text. The frontend would have to re-implement the same prefix grammar to extract the move kind — brittle and already done on the server.The server should extract the move kind at projection time and expose it as a nullable typed field.
3 — No
episode_id— messages from one episode cannot be groupedAn episode's messages are scattered through the desk journal interleaved with whatever else happened. There is no grouping key. The frontend cannot tell where an episode started, where it ended, or which commit row closed it. The
HIVE_REPORT_AUTHORsummary (src/hivemind/episode.rs:1043) that closes each episode is journaled as a plainAgentReply— indistinguishable from a normal reply once theagent_idis the only marker.4 — No
elidedflag on aside rowsA viewer not in an aside's audience should see a stub (
[aside between fleet_tech and route_planner]) rather than content. Today the history read applies no viewer-scoped elision — it returns all content to all viewers (src/server/chat_history.rsprojectarm matchesAgentReplywithout filtering onaudience). This is documented as correct for operators (docs/spec/runtime/hivemind-asides.md, "An operator and every person reads every aside in full"), so viewer-scoped elision only applies to agent peers. But the console should be able to render the elided stub without re-running the episode.5 — The frontend has no HiveMind-aware rendering
frontend/src/views/chat/MessageRow.tsxrenders everyAgentReplyidentically. There is no:Open issues that this closes or partially addresses: #1956 (first-person-collapsed transcript), #1957 (missed teammate reply after rebind), #1959 (desk_context has no production consumer).
Solution
Layer 1 —
MessageView: four new fieldsProjection (
projectfn inchat_history.rs): when matchingCompanyEvent::AgentReply { audience, text, .. }, extract the move kind from the text prefix, passaudiencethrough, computeelidedfrom the viewer's identity vs. the audience list.Episode id: thread the trigger's
EventSeqthroughEpisodeDriver::run(src/hivemind/episode.rs:211) and journal it on everyAgentReplythe episode writes (a newepisode_id: Option<String>field onAgentReply, additive:#[serde(default, skip_serializing_if = "Option::is_none")]).Layer 2 — Frontend: HiveMind-aware message rendering
frontend/src/lib/chat.ts— extend theMessagetype:MessageRow.tsx— two new rendering modes:Move badge — when
hiveMoveis set, render a left-border stripe whose color encodes the move kind (propose = blue, evidence = amber, support = green, commit = emerald + bold, question = purple, defer = slate, aside = dashed border). The badge sits left of the author name so the room's deliberation structure reads as a visual rhythm even when text is collapsed.Aside / elided — when
audience.length > 0, render a compactaside between {author} and {audience}label in the header. Whenelided: true, replace the bubble body with an italicized stub and suppress tool steps.MessageTimeline.tsx— episode grouping:Collect consecutive messages sharing the same
episodeIdinto a collapsible<EpisodeBlock>. The block header shows participant avatars, turn count, and an outcome chip ("committed" / "deadlocked" / "exhausted" / "deferred") extracted from theHIVE_REPORT_AUTHORrow that closes the episode. Default to expanded for the most recent episode, collapsed for older ones.Cross-desk referral sub-threads: a row whose
agent_idisHIVE_REFERRAL_AUTHORrenders as a quoted block with an outbound arrow and the referred desk's name; the answer row that follows it is indented under it.Layer 3 — New route:
#/desks/{id}/transcriptA dedicated read-only view, separate from the conversational
#/chat/{desk_id}route.What it is not: it is not a chat. No message composer, no send button, no reply affordance. It is an observer surface — the operator watches what the room produced.
What it shows:
N turns · M members · outcome · duration.InflightRunBarSSE stream already carries this signal).Route registration (
frontend/src/App.tsx): add/desks/:deskId/transcriptbeside the existing/chat/:threadIdroutes.Navigation entry: the desk's channel header (
ChatHeader.tsx) gains a Transcript tab/link that opens the view for the current desk. The link is only rendered when the desk hashive.enabledset in the manifest (or defaults to enabled by having >= 2 members).Sidebar: the desk's row in
ChannelRail.tsxgets a HiveMind indicator icon so the operator can see at a glance which desks are running episodes.Acceptance criteria
MessageViewgains four fields —hive_move: Option<HiveMoveKind>,episode_id: Option<String>,audience: Vec<String>,elided: bool— each additive (#[serde(default)],skip_serializing_if) so no stored record needs migrating and every existing test compiles unchanged.AgentReplygainsepisode_id— journaled byEpisodeDriver::runfor every message the episode writes; the trigger's event seq (cast to string) is the id; additive on the same terms.!propose,!evidence, etc.) is parsed once in theprojectfn inchat_history.rsand never duplicated in the frontend. Unknown prefixes yieldHiveMoveKind::Unknown, not an error.audience(and is not an operator) receivestext: "", elided: true. Operators always receive the full text. The viewer id is the already-resolvedbyfrom the auth context.MessageRow.tsx— eachHiveMoveKindhas a distinct left-border color and a text label ("proposes", "supports", "commits", etc.) beside the author. No badge whenhiveMoveis absent.audienceset renders an aside header label; an elided row renders the stub instead of message content, with no tool-call steps shown.MessageTimeline.tsx— messages sharing anepisodeIdare grouped; the block header shows outcome and stat line; most-recent episode is expanded by default.#/desks/{id}/transcriptroute — new read-only view, no composer, paginated episode history, collapsible episode blocks, live indicator, aside rendering, cross-desk referral indentation.ChatHeader.tsxon a HiveMind desk shows a Transcript tab/link;ChannelRail.tsxshows a HiveMind indicator on hive-enabled desks.desk-transcript.spec.ts— drives the vending_machine_co bundle (already incompanies/): loads#/desks/ops/transcript, asserts episode blocks are present, asserts the commit row has thecommitbadge, asserts aside rows show the audience label. Uses mock-plan scripting followingorchestration-simulation.spec.ts— not gated onLIVE_BRAIN.cargo testpasses; the four new additive fields onAgentReply/MessageViewdo not break any deserialization or assertion.Related
src/hivemind/episode.rs:1043—EpisodeDriver::reportthat writes the closingHIVE_REPORT_AUTHORrow; the outcome chip reads this rowsrc/hivemind/aside.rs— aside authorization logic; viewer-scoped elision mirrors what the session log already does for agent projectionsrc/server/chat_history.rs:300—MessageView— the struct that gains the four new fieldscompanies/vending_machine_co/— the bundle that already runs asides, referrals, and multi-episode deliberation; drives the Playwright spec