← all docs · north-os21 July 2026

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):

LevelLabelReasoning blockInternal tool activityWorking notesHousekeeping confirmations
0Answer onlyhidden (suppressed in the renderer)hidden (suppressed)hidden; hoisted live line while streaming onlyinline chip (as today — kept, see below)
1Compact (default — today's behavior, byte-identical)collapsed trigger; auto-opens while streaming (today's defaultOpen={running})collapsed group, narrated triggerinside the tool group; latest note as live line while streaminginline chip (as today)
2Activity opencollapsed trigger; auto-opens while streaminggroup renders default-open (still collapsible)visible (group open)inline chip
3Everything opendefault-opengroup default-openvisibleinline chip
4Full transcript ("like before")default-opengroup 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):

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):

API

Following the existing authed settings surface in apps/web (Hono + apiAuth, Zod at the boundary):

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:

  1. env.detailLevel when the host supplies it (tests, fixtures, or a host that wants to force a level) — always wins;
  2. else one GET /api/settings/chat via env.fetch/apiUrl on mount (the Word pane runs same-origin inside apps/web, so this needs no special credential handling anywhere);
  3. 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

  1. Placement table test (extends the thread-working-notes.test.tsx harness): 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).
  2. Invariant guard — request path: exercise live-chat.tsx's real transport (prepareSendMessagesRequest) with detailLevel: 0 and detailLevel: 4 in the environment and assert the produced POST /api/chat bodies are byte-identical. Standing review rule: apps/web/lib/chat and the chat request builder must never read user_chat_preference or detailLevel.
  3. Invariant guard — persistence path: convertMessage output for a fixture server payload is identical regardless of level (level applied strictly at render).
  4. 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).
  5. Settings API: GET default, PATCH round-trip, out-of-range rejection.
  6. 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)