Imported from dot-stbl/comuki (
dashboard/src/domains/chat/AGENTS.md). Install upstream withnpx skills add dot-stbl/comuki --skill chat. Copyright stays with the author.
domains/chat
Purpose
The console — a full agent pult, not an assistant bolted to the side. It is the same control plane the screens drive, reached by typing. Its tools are the Orchestration API and the lead model.
Routes
/chat— the console (src/routes/chat/index.tsx)/chat/init?step=— the onboarding wizard (src/routes/chat/init.tsx)
Containers
The console has exactly one implementation and two containers:
/chat— the screen, with a URL that can be linked and bookmarked.- The dock (
ui/chat-dock.tsx) — a floating trigger over the board opening a modal bottom sheet (shared/ui'sBottomSheet), resizable by its top edge, able to fill the window.
Both render the same ChatConsole (ui/chat-console.tsx). Not a copy, not a
trimmed variant: one thread, one composer, one proposal card, one set of
tool-call records. The rule is the same one that forbids a second duty screen —
a state change confirmed in either container lands in the same journal, and the
day two implementations disagreed, the operator would believe the wrong one.
A test asserts it structurally (same component, same class) — ui/chat-dock.test.tsx.
The dock holds its state outside the component tree (memory in
ui/chat-dock.tsx): every screen mounts its own shell, so a navigation is a
remount, and an open sheet, an open conversation and a half-typed draft are
the operator's, not the box's. The dock closes itself when a link inside the
console is pressed — a hand-off's answer is a screen, and navigating under a
scrim would be the sheet lying about where it is.
The scrim is a deliberate trade the owner took knowingly: the board is not
readable while the sheet is open. The compensation is the seeded reference:
opening the dock reads what the location says the operator was looking at
(model/references.ts → referenceFromLocation) and offers it as a chip in
the composer — a suggestion, removable in one gesture, whose id rides along
with the next message as text.
Three settled decisions
-
It proposes; a human confirms. Every state change is a
Proposal(model/types.ts) with two controls that keep their words. There is no path from rendering a proposal to acting on one — seeui/proposal-card.tsx. -
Its acts land in the same journal. Confirming writes through
shared/api/mock/chat.store.tsinto the run store, and the runs query is invalidated. A run stopped from the console is a run stopped. -
It renders what the conversation produced; it hands off what is a product surface. Prose, code, diagrams, questions, decisions came out of this turn, so the console draws them — see
model/types.ts'sMessagePartand the renderer table inui/message-part.tsx. Runs, the queue, knowledge and cost each have a screen of their own, so the console links to them:ChatHandoffsasksresolveQueryfromapp/searchfor the destinations, so the console and the command palette cannot disagree.The test is one question: does the thing have its own screen? If it does, link to it — a second runs table is a second duty screen and the day the two disagree the operator believes the wrong one. If it does not, render it, because a patch flattened into a paragraph and a plan summarised into a sentence are not hand-offs, they are losses.
This used to read "it hands off rather than rendering a second product", which read as a ban on drawing anything at all — a code block inside a message was formally forbidden by the charter. The owner refined it: the boundary was never about rendering, it was about owning a surface twice.
Public exports
ChatPage, InitWizardPage, ChatDock via @/domains/chat.
Invariants
- Chat tools check the same permissions as REST, resolved per project with
can(session, permission, projectId).model/proposals.tsis the only place the mapping from act to permission lives. - A refusal renders the control present,
aria-disabled, with the reason ondata-denied, and swallows the click. Neverdisabledfor a permission. - Identifier shapes come from
app/search/shapes.ts. Only the keyed tier is allowed inside prose — seemodel/references.ts. The dock's seed reads the same shapes out of the location, and a seed the session cannot follow is offered never, for the same reason prose renders it as plain text. - No language model. Replies are scripted in
shared/api/mock/chat.seed.ts. Every state the thread can render is reachable from a seed there;resetChatSessions()is the test contract. - A turn is an ordered list of parts (
MessagePart), and the list is frozen: text, code, diagram, thinking, tool, handoff, plan.questionanddecisionare P2. The composition dispatches throughRecord<PartKind, …>inui/message-part.tsx, so a kind with no arm fails at the table's declaration rather than rendering an empty row. A flat message with no part list is derived inmodel/parts.ts— one renderer, two shapes, and the wire seam is the single marked function inapi/mappers.ts. - Prose is markdown (
react-markdown+remark-gfm), and neverrehype-raw: raw HTML does not enter the thread, so no element on this surface is one the component map did not write. The parser and the syntax highlighter are behind a dynamic import — the dock is mounted on every screen, so neither may ride the first paint. - The thread is pinned to the newest turn only while it is already at the bottom, and offers jump to latest when it is not. It virtualizes past sixty settled turns and renders the plain list below that.
- The streaming reply is rendered outside the
role="log"region and isaria-hiddenwhile it arrives, and is not parsed as markdown until it settles — half a document is a different document. Seeui/chat-thread.tsxandui/message-prose.tsx. - The processing dynamics are derived, never stored (
model/dynamics.ts): the phase badge (thinking/plan/done), the iteration, the thinking steps and the metrics line are computed from the parts and themetaa turn already carries. The working-out is visible while the turn streams (steps, checks, the newest line spinning) and folds as evidence once it settles. The metrics line renders only figures the turn reported — nothing zeroed, nothing faked. The typing pause (awaitingon the thread, from the console's send mutation) is the one turn state with no row of its own. The memory digest row the host journals (a system message with the"memory digest fed to the brain:"prefix) renders as the compactmemory: N factschip, not as prose. - Loading is a state, and it is not the empty one. The thread's Nothing
said yet is only reachable once both reads have answered; until then the
centre column is a
Skeletonand the rail is one of its own (ui/chat-console.tsx,ui/chat-sessions.tsx). The rail's empty reading isScreenState, and it stays silent while the sessions read is failing — the centre already says that once, with the one retry, and a second reading of the same dead wire would be the wrong one. - A refused act is said out loud, where it happened. The composer clears on the gesture — a box that waits for a round trip feels broken on every send that works — so the console holds the refusal: the words go back into the box when the box is empty, and ride in the band beside it when the operator has already started a new thought, which is newer than the message that failed. Every failure is stamped with the conversation it happened in and shown only there; a conversation that could not be started belongs to none, so it carries no stamp and is shown anywhere. A proposal decision that did not land takes the same path — a confirm that silently does nothing is the console telling the operator an act reached the journal when it did not.
- The dock is hidden — not explained — without
chat.use, the way the rail hides what a role cannot reach. The console is not a rail section: the floating trigger is its one door in the chrome, decided by the owner. The/chatroute stays as the URL-addressable reading (and the wizard's neighbour), linked from nothing; the wizard's entry point rides in the sheet's bar, because an entry point must live in the container that is reachable.