Chat detail level — design spec
Status: Planned
Author: Christophe
Date: 2026-07-21
Repo: north-os (packages/chat-ui; storage + settings in packages/db + apps/web; packages/chat-runtime and packages/agent-runtime remain byte-identical)
Related: docs/superpowers/specs/2026-07-16-working-notes-separation-design.md; docs/superpowers/specs/2026-07-16-housekeeping-side-channel-design.md; commits e35aae81, 469456a7
Problem
The chat agent used to read as verbose: working notes, tool activity, and housekeeping confirmations rendered inline in the answer flow. Commits e35aae81 (working notes → activity chrome) and 469456a7 (housekeeping → chrome) made the surface ultra-compact. Different lawyers want different amounts of visible work: some want just the answer, some want to watch everything the agent does — the "before" presentation.
We need a per-user detail level that controls how much of the agent's work is surfaced.
The invariant (load-bearing)
The detail level changes only what is surfaced to the user. It must not change the agent's reasoning, tool choices, judgment, or work product. Same decisions, same tools, same quality at every level; only the presentation differs.
This spec enforces the invariant structurally: the level is a pure UI surfacing control. It never crosses the /api/chat boundary, never enters configurable, never touches the system prompt. The request the server receives, the graph execution, and the persisted chat_message rows are byte-identical across levels. There is no prose-verbosity prompt lever in v1.
Justification: git archaeology shows the "chatty before → compact now" shift was entirely UI-side (e35aae81, 469456a7); the agent's prose rules in prompt.ts did not change. Therefore level 4 ("like before") is reproducible by surfacing alone. A prompt lever ("narrate more/less") was considered and rejected for v1: even carefully phrased as presentation-only, it risks judgment drift and would force the invariant to be defended by tests instead of by architecture. If real usage shows level 4 still reads too terse, a narration-only prompt block can be a follow-up spec with its own eval gate.
Scale — five stops, 0–4
Christophe said "3 or 4 levels" but named both endpoints explicitly ("level 0 = even more compact than now", "level 4 = fully expanded, like before"). We honor the named endpoints: a 0–4 scale, five stops. (Alternative considered: 4 stops labeled 0–3 — rejected because "level 4" was named verbatim and the five stops each map to a real mechanism difference below.)
What each stop surfaces (all mechanisms already exist; the level only gates them):
| Level | Label | Reasoning block | Internal tool activity | Working notes | Housekeeping confirmations |
|---|---|---|---|---|---|
| 0 | Answer only | hidden (suppressed in the renderer) | hidden (suppressed) | hidden; hoisted live line while streaming only | inline chip (as today — kept, see below) |
| 1 | Compact (default — today's behavior, byte-identical) | collapsed trigger; auto-opens while streaming (today's defaultOpen={running}) | collapsed group, narrated trigger | inside the tool group; latest note as live line while streaming | inline chip (as today) |
| 2 | Activity open | collapsed trigger; auto-opens while streaming | group renders default-open (still collapsible) | visible (group open) | inline chip |
| 3 | Everything open | default-open | group default-open | visible | inline chip |
| 4 | Full transcript ("like before") | default-open | group default-open (tools stay grouped — that is the true pre-e35aae81 presentation) | inline as answer-flow prose (note membership emptied — no re-homing, no muted styling) | inline as ordinary prose (re-homing bypassed) |
Precisions — mechanics (these are load-bearing; review found the naive versions wrong):
- Level 0 is renderer suppression, never
groupBy. InMessagePrimitive.GroupedParts, agroupBythat returnsnullrenders the part inline and visible (that is how answer text and user-facing cards render today). Hiding must happen in the render callback: at level 0 the branches forgroup-reasoning,group-chainOfThought/group-tool, internal (non-user-facing)tool-callparts, and working-note text parts returnnullso nothing is emitted. Tests must assert these nodes are absent from the DOM, not merely collapsed. - Level 4 gates note membership at the source.
workingNotePartsis consulted in three places (groupBy classification, the muted-styling text branch, the live line) andhousekeepingNotePartsin two. At level 4 both derived collections are computed as empty (memoized on parts + level), so every consumer naturally renders the text as full answer prose. Tool calls keep the group path — the pre-e35aae81code grouped tools too; only text was inline. - Reasoning gate is additive and lives in
thread.tsx. The live reasoning render is thegroup-reasoningcase inthread.tsx(ReasoningRoot defaultOpen={running});reasoning.tsx'sReasoningGroupImplis deprecated and unused. The gate isdefaultOpen = running || level >= 3— levels 1–2 must preserve today's open-while-thinking behavior or level 1 stops being a no-op. - Group default-open needs a prop thread.
ToolGroupBlocknever passesdefaultOpentoToolGroupRoottoday; add the prop and set it at levels 2–4. Individual tool cards (ToolFallback, registered cards) keep their own open/closed defaults at every level — forcing them open would require refactoring per-card state for no fidelity gain ("before" had collapsed cards). - User-facing tool cards are never gated. Confirm widgets, document cards, question widgets, reply drafts — anything
isUserFacingToolPartor MCP-app filtered out of the group today — render at every level, including 0. They are work product and/or interactive gates, not narration. - The housekeeping "chip" is the inline re-homed
<p data-slot="aui_housekeeping-note">— it is not inside the tool group. Levels 0–3 applycollectHousekeepingNotePartsexactly as today (DOM position unchanged — level 1 stays byte-identical); level 4 bypasses the re-homing so the confirmation renders as ordinary prose. - Level 0 keeps the housekeeping chip — an explicit exception to "answer only". A chip like "Time entry proposed in Clio" is the only surfaced record that a bookkeeping write happened. Hiding it would hide an action, not narration — auditability wins. (Flagged for Christophe: strike this exception if strict answer-only is preferred.)
- Level 0 while streaming ("shows just thinking"): a new hoisted live line in the assistant-message renderer — the existing
latestWorkingNoteline lives insideToolGroupBlock, which does not mount at level 0. While the message is running it shows the latest working note, else a neutral working shimmer; the existingThinkingIndicatorbecomes level-0-aware so the two never double up and a reasoning/tool-only turn never renders an empty message. Gone on completion. - Levels are monotone: every stop surfaces at least what the stop below it surfaces.
- "Default-open" means the collapsible mounts open; the lawyer can still collapse it. Level 0's "hidden" is not collapsible — the chrome is absent from the DOM.
Default
Level 1 for existing and new users — exactly today's behavior, so nobody is surprised by the feature landing. An unset/missing preference reads as 1 everywhere.
Storage — server-persisted, user-scoped
Although v1 is pure UI, the preference is server-persisted so it roams across devices and hosts (web app, Word task pane, and any future surface share @workspace/chat-ui), and so a future prompt-lever follow-up would not need a storage migration. (Alternative considered: localStorage like the appearance theme — rejected: per-device drift for a preference about the same conversation surface, and no roaming into the Word pane.)
Pattern copied from matter_hidden (packages/db/src/schema/matter.ts) — user-scoped, app_role-written, no RLS policy, GRANT in provision-roles.ts (never in a migration):
- New table
user_chat_preference:user_idtext PK, FK →user.idon delete cascadechat_detail_levelinteger not null, CHECK 0–4 (modeled in schema TS)updated_attimestamptz not null default now
- User-scoped (not org-scoped): the detail level is a personal presentation preference, the same for a user across every org they belong to — like the appearance theme, unlike
matter_hiddenwhich annotates org data. Noorganization_idcolumn, no RLS; access is alwaysWHERE user_id = session.userIdfromapp_rolecode. - Migration generated with
bun db:generateonly. Journalwhenwatermark checked perpackages/db/MIGRATIONS.md. - GRANT (
SELECT, INSERT, UPDATEtoapp_role) added inpackages/db/scripts/provision-roles.ts.
API
Following the existing authed settings surface in apps/web (Hono + apiAuth, Zod at the boundary):
GET /api/settings/chat→{ detailLevel: 0|1|2|3|4 }(missing row →1)PATCH /api/settings/chatbody{ detailLevel: 0|1|2|3|4 }→ upsert, returns the saved value
The route validates with Zod and rejects anything outside the integer 0–4 range. No org gate — self-service personal setting, like avatars. The routes live in a small dedicated preferences router mounted alongside the existing routers in apps/web/lib/api/app.ts — not bolted onto settingsMediaRouter, which is media-specific (R2 uploads).
Settings UI
New section on Settings → Appearance (apps/web/app/(settings)/appearance): the page already owns "how North looks"; detail level is presentation, so it belongs there. (Alternative considered: Account page — rejected, Account is identity/security.) The control is a five-stop selector (segmented control or equivalent shadcn primitive) with the stop labels from the table and a one-line description each, written in the sober house tone ("How much of the work North shows while it answers. The work itself is identical at every level."). The Appearance page is client-only (no server data today): the control fetches GET /api/settings/chat on mount to seed the selected stop (skeleton/disabled until it resolves), then saves on change via optimistic PATCH.
Plumbing into the chat surface
ChatEnvironment (packages/chat-ui/src/chat/chat-environment.tsx) is the existing host seam and gains one optional field:
export type ChatDetailLevel = 0 | 1 | 2 | 3 | 4
interface ChatEnvironment {
/** How much of the agent's work the surface renders. Presentation only. Default 1. */
detailLevel?: ChatDetailLevel
}
Resolution is centralized in chat-ui — review found LiveChat mounts on at least five web surfaces (deal chats, task chats, the automations board, the skill editor) plus the Word pane; per-page wiring would silently leave missed hosts at level 1. Instead a single chat-ui hook resolves the level for every host:
env.detailLevelwhen the host supplies it (tests, fixtures, or a host that wants to force a level) — always wins;- else one
GET /api/settings/chatviaenv.fetch/apiUrlon mount (the Word pane runs same-origin insideapps/web, so this needs no special credential handling anywhere); - else / until resolved: default 1 (today's rendering — a brief first-paint at the default is acceptable and invisible to level-1 users).
The renderers in thread.tsx read the resolved level and apply the mechanics above (leaf suppression at 0; membership emptying at 4; additive defaultOpen at 2–4). The level is applied strictly at render time over the immutable parts array. Changing the level re-renders existing messages — the full transcript is always persisted, so a lawyer can flip to level 4 after the fact and see the work chrome for past turns.
Test plan
- Placement table test (extends the
thread-working-notes.test.tsxharness): one fixture message (text → tool → text → tool → text, plus reasoning, plus a housekeeping tail) rendered at each of the five levels; assert the DOM matches the table row — level 0 asserts the reasoning/tool/working-note nodes are absent (not collapsed); level 4 asserts note text renders in the answer-prose slot (full markdown treatment, no muted working-note styling, no housekeeping re-homing). - Invariant guard — request path: exercise
live-chat.tsx's real transport (prepareSendMessagesRequest) withdetailLevel: 0anddetailLevel: 4in the environment and assert the producedPOST /api/chatbodies are byte-identical. Standing review rule:apps/web/lib/chatand the chat request builder must never readuser_chat_preferenceordetailLevel. - Invariant guard — persistence path:
convertMessageoutput for a fixture server payload is identical regardless of level (level applied strictly at render). - Streaming: at level 0 a running turn shows exactly one live line and no chrome (including a reasoning-only or tool-only turn — never an empty message, never a doubled indicator); at levels 1–2 the reasoning block still auto-opens while streaming exactly as today; at level 1 today's streaming tests pass unchanged (level 1 default means the existing suite doubles as the regression net).
- Settings API: GET default, PATCH round-trip, out-of-range rejection.
- Selector/referential-stability contracts from the React #185 fixes are preserved (level comes from context, not from a new derived-object selector).
Non-goals (v1)
- No prompt/prose lever, no
configurablechange, no agent-runtime change of any kind. - No per-thread or per-message override; no in-chat quick toggle (Settings only).
- No org-level policy/default.
- Polaris/desktop surfaces untouched (they don't render this chrome).