Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
3d0159f
chore(studio): import Atrium UI (filtered vendor drop)
jtenniswood Aug 18, 2026
ee065e2
chore(studio): correct stray Proprietary license headers
jtenniswood Aug 18, 2026
fd980bf
chore(studio): rebrand and retool (npm, Node 22, daemon-only shims)
jtenniswood Aug 18, 2026
09a1cfc
feat(studio): graft the hardened server tier from feat/studio-module
jtenniswood Aug 18, 2026
1fc6aa2
feat(studio): merge the protocol seam (events, sessions, schedules)
jtenniswood Aug 18, 2026
bfdbf40
feat(studio): daemon-only hooks behind a shared runtime status
jtenniswood Aug 18, 2026
ffdc2c3
feat(studio): wire the five surfaces daemon-first
jtenniswood Aug 18, 2026
46bb75b
test(studio): browser e2e over a fixture daemon
jtenniswood Aug 18, 2026
646cd58
ci(studio): workflow, dependabot, and Taskfile integration
jtenniswood Aug 18, 2026
ffa2e36
docs(studio): ADRs 0228/0229, guides, tracker, llms
jtenniswood Aug 18, 2026
fc91e86
fix(studio): npm-10 lockfile and the audited Next bump
jtenniswood Aug 18, 2026
de1c999
docs: regenerate llms.txt after the readiness-tracker row
jtenniswood Aug 18, 2026
42e1264
feat(studio): settings subpages, identity preferences, empty-state po…
jtenniswood Aug 18, 2026
82c7f0f
fix(studio): re-sync the lockfile under npm 10 and repoint the settin…
jtenniswood Aug 18, 2026
6ab5678
feat(studio): Figma shell redesign — top nav, card surface, right ses…
jtenniswood Aug 18, 2026
b3bc4a3
feat(studio): left-align the conversation column, widen the session l…
jtenniswood Aug 18, 2026
b950173
fix(studio): a draft's first stream survives its own session mint
jtenniswood Aug 18, 2026
5a00650
feat(studio): title spinner while generating, one shared panel width
jtenniswood Aug 18, 2026
01a31c5
feat(studio): darker dark-mode shell gradient, header bar on drafts
jtenniswood Aug 18, 2026
a403521
fix(studio): pin the Taskfile's npm install to npm 10
jtenniswood Aug 18, 2026
9896a47
feat(studio): setting to dock the session list left or right
jtenniswood Aug 18, 2026
f234078
feat(studio): sidebar toggle docks on the header edge nearest the panel
jtenniswood Aug 18, 2026
e8e370c
fix(studio): appease biome's key and hook-dependency rules
jtenniswood Aug 18, 2026
3cb1224
fix(studio): mobile shell — 500px breakpoint everywhere, halved margi…
jtenniswood Aug 19, 2026
8d8d53d
feat(studio): PWA installability for Android and iOS
jtenniswood Aug 19, 2026
a4266eb
feat(studio): mobile interaction round — sheets, threads, swipe actio…
jtenniswood Aug 19, 2026
c7a565e
feat(studio): mobile bottom tab bar + native settings drill-down
jtenniswood Aug 19, 2026
05239b1
feat(studio): chat-style settings subpage header, avatar downscale, l…
jtenniswood Aug 19, 2026
17141cc
feat(studio): native-style settings list, Memory moves into Settings
jtenniswood Aug 19, 2026
bd1da2b
fix(studio): nav pill gap, mobile-only trims
jtenniswood Aug 19, 2026
b20bca9
fix(studio): allow dev-server assets for LAN origins
jtenniswood Aug 19, 2026
6852152
fix(studio): iOS standalone safe areas
jtenniswood Aug 19, 2026
2bb8bbb
style(studio): neutral settings icon squares
jtenniswood Aug 19, 2026
ece69b5
feat(studio): mobile composer — one-line layout, picker bottom sheets
jtenniswood Aug 19, 2026
e77bff2
feat(studio): tab bar yields to the keyboard
jtenniswood Aug 19, 2026
13722f3
fix(studio): kill the iOS launch gap for real, disable pinch-zoom
jtenniswood Aug 19, 2026
2944758
style(studio): full-bleed content card on mobile
jtenniswood Aug 19, 2026
ebe65b0
feat(studio): docked mobile composer — one bar, options in a sheet
jtenniswood Aug 19, 2026
3cfff5d
revert(studio): mobile navigates from the top bar again
jtenniswood Aug 19, 2026
433e521
feat(studio): UI text-size setting
jtenniswood Aug 19, 2026
bf95b60
fix(studio): keyboard resizes the layout viewport on Android
jtenniswood Aug 19, 2026
546e2a2
style(studio): mobile type/icon bump, slimmer bars, options glyph
jtenniswood Aug 19, 2026
37b8732
feat(studio): settings UX round — scale slider, select fields, mobile…
jtenniswood Aug 19, 2026
8d751d2
feat(studio): agent identity page — picture and name, split from Profile
jtenniswood Aug 19, 2026
106c8ef
feat(studio): scroll-to-bottom control in chat
jtenniswood Aug 19, 2026
10979d7
style(studio): slimmer desktop gradient border
jtenniswood Aug 19, 2026
57c9492
style(studio): mobile base font-size up to 18px
jtenniswood Aug 19, 2026
6fa28f8
style(studio): trim the transcript's bottom reserve on mobile
jtenniswood Aug 19, 2026
8bc2c41
feat(studio): pinned-follow auto-scroll in chat
jtenniswood Aug 19, 2026
640782e
feat(studio): chat polish — usage in the menu, activity line, roomier…
jtenniswood Aug 19, 2026
4cd0ea0
style(studio): page titles down to text-3xl on desktop too
jtenniswood Aug 19, 2026
bd159af
fix(studio): interface-scale slider no longer jitters mid-drag
jtenniswood Aug 19, 2026
6e92284
feat(studio): long-press a chat row for its actions on touch
jtenniswood Aug 19, 2026
6265530
feat(studio): draft chat uses the standard composer position
jtenniswood Aug 19, 2026
9a0b8c7
feat(studio): interface scale becomes a − / + stepper
jtenniswood Aug 19, 2026
6be2315
feat(studio): image attachments reach the model; usage is a chat total
jtenniswood Aug 19, 2026
9c95e86
style(studio): biome reflow of the shortened title class strings
jtenniswood Aug 19, 2026
da50dd5
fix(studio): chat row "…" menu shows on hover at every desktop width
jtenniswood Aug 19, 2026
b0f1f84
fix(studio): big and HEIC images re-encode before sending
jtenniswood Aug 19, 2026
9f9cf22
fix(studio): whole-package lint pass
jtenniswood Aug 19, 2026
cb65c25
fix(studio): satisfy knip's dead-code gate
jtenniswood Aug 19, 2026
f6052d4
feat(studio): message queue with steer, edit, and delete
jtenniswood Aug 19, 2026
eb205b6
Merge remote-tracking branch 'origin/main' into feat/studio-mobile
jtenniswood Aug 20, 2026
e532776
feat(server): unary HTTP steer endpoints (steer / steer-cancel)
jtenniswood Aug 20, 2026
a03cf05
feat(studio): steering, threads, mock tour, schedules/skills/settings…
jtenniswood Aug 20, 2026
8dd94b0
chore: ignore Studio-managed local state; regenerate llms.txt
jtenniswood Aug 20, 2026
6a9d4d3
fix(studio): skill dialogs and Manage row polish
jtenniswood Aug 20, 2026
843b102
fix(studio): tighter page gutters; serif greeting on the empty chat
jtenniswood Aug 20, 2026
89745ed
fix(studio): mode menu language + explainers; canvas PDF crash
jtenniswood Aug 20, 2026
2749962
fix(studio): avatar removal is a hover × on the picture
jtenniswood Aug 20, 2026
11430cb
refactor(studio): memory list as a table; detail as a dedicated page
jtenniswood Aug 20, 2026
1271342
fix(studio): appearance stepper width; agent name above picture
jtenniswood Aug 20, 2026
a4cde73
feat(studio): "You" settings section with display name; detail-page p…
jtenniswood Aug 20, 2026
becd408
fix(studio): Messages settings row matches Appearance; concise subtitle
jtenniswood Aug 20, 2026
4f9b2d0
feat(studio): provider management — key health, test, remove, guided add
jtenniswood Aug 20, 2026
181a247
feat(studio): client half of the runtime active-provider switch
jtenniswood Aug 20, 2026
d9eee78
fix(studio): identity cards align; add-provider dialog reads as 3 steps
jtenniswood Aug 20, 2026
fba775b
fix(studio): opaque composer banners; search input clears its close b…
jtenniswood Aug 20, 2026
6c30188
refactor(studio): every settings page speaks the Appearance grammar
jtenniswood Aug 20, 2026
e7a59de
feat(studio): controller route for the live provider switch
jtenniswood Aug 20, 2026
fcb79de
fix(studio): thinking dots actually bounce
jtenniswood Aug 20, 2026
11a3244
fix(studio): quoted user messages render as quotes; mobile thread scr…
jtenniswood Aug 20, 2026
17d3e9a
fix(studio): queued messages are one opaque group with a kebab per row
jtenniswood Aug 20, 2026
4f2f607
feat(studio): real model picker; dialog & gateway polish; quote/threa…
jtenniswood Aug 20, 2026
8cfcf44
fix(studio): steer replies render in order; one clean provider list
jtenniswood Aug 20, 2026
0b6af4b
fix(studio): steer text lands straight in the chat; approval panel sa…
jtenniswood Aug 20, 2026
edf5345
feat(studio): files a chat writes render as openable attachments
jtenniswood Aug 20, 2026
fbcd886
fix(studio): produced-file chips read the daemon's real Write arg key
jtenniswood Aug 20, 2026
37e9230
fix(studio): router model pickers offer the daemon's whole inventory
jtenniswood Aug 20, 2026
b9d8b72
refactor(studio): Appearance becomes Personalize and absorbs Messages
jtenniswood Aug 20, 2026
2343ea5
refactor(studio): Personalize absorbs notifications with a bell test
jtenniswood Aug 20, 2026
63f6a23
feat(studio): progressive model router; uniform Personalize controls
jtenniswood Aug 20, 2026
e22386d
fix(studio): the Enter-behavior row is labeled "Message queuing"
jtenniswood Aug 20, 2026
f9d59ec
fix(studio): "Your name" subtitle asks what the agent should call you
jtenniswood Aug 20, 2026
4e39bba
feat(studio): attachment previews everywhere, consistent chips
jtenniswood Aug 20, 2026
74ecaa2
fix(studio): thinking indicator aligns with the message grid
jtenniswood Aug 20, 2026
32065cc
feat(studio): attachments survive refreshes; compaction reads as a di…
jtenniswood Aug 20, 2026
7f1b0c2
fix(studio): thread replies drop the redundant root quote
jtenniswood Aug 20, 2026
9692b56
feat(studio): thread panel menu (tools toggle, open as full chat); sk…
jtenniswood Aug 20, 2026
23a0cab
fix(studio): mock tour drops its canned thread — threads are real now
jtenniswood Aug 20, 2026
ca0ec3e
fix(studio): attachment chips share one fixed height
jtenniswood Aug 20, 2026
942f7c9
refactor(studio): lean schedule form; markdown lists match body rhythm
jtenniswood Aug 20, 2026
987f87b
feat(studio): two-step skill creation; quieter router and provider cards
jtenniswood Aug 20, 2026
734a587
feat(studio): skill detail browses folder skills; tighter page bottoms
jtenniswood Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,19 @@ updates:
update-types:
- minor
- patch

# Studio (the Node web client) — npm.
- package-ecosystem: npm
directory: "/studio"
schedule:
interval: weekly
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
labels:
- dependencies
groups:
npm-minor-patch:
update-types:
- minor
- patch
90 changes: 90 additions & 0 deletions .github/workflows/studio.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Studio

on:
pull_request:
paths:
- "studio/**"
- ".github/workflows/studio.yml"
push:
branches: [main]
paths:
- "studio/**"
- ".github/workflows/studio.yml"

permissions:
contents: read

jobs:
checks:
name: Build, test, lint, typecheck, audit
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: studio/.nvmrc
cache: npm
cache-dependency-path: studio/package-lock.json

# No package in the tree needs install scripts (Next/sharp ship prebuilt
# binaries), so scripts stay off — supply-chain surface CI never runs.
- name: Install
working-directory: studio
run: npm ci --ignore-scripts

- name: Lint, typecheck, dead-code
working-directory: studio
run: |
npm run lint
npm run typecheck
npm run knip

- name: Unit tests
working-directory: studio
run: npx vitest run

- name: Build + hermetic server-tier suite
working-directory: studio
run: npm run test:server

- name: Audit
working-directory: studio
run: npm audit --audit-level=high

- name: License headers
run: |
if git grep -l "SPDX-License-Identifier: Proprietary" -- studio/; then
echo "Proprietary SPDX headers are not allowed in studio/" >&2
exit 1
fi

# Browser smoke over the real proxy tier against the fixture daemon.
# Separate job so a browser-infra flake never masks the checks above.
e2e:
name: Playwright (fixture daemon)
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: studio/.nvmrc
cache: npm
cache-dependency-path: studio/package-lock.json

- name: Install
working-directory: studio
run: |
npm ci --ignore-scripts
npx playwright install --with-deps chromium

- name: Build and test
working-directory: studio
run: npm run test:e2e
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ coverage.*
# Personal, local-only Claude Code instructions and running-state notes
/CLAUDE.local.md
/HANDOFF.md

# Studio managed-mode local state (controller-pinned skills/memory dirs).
/.mecatl/

# Ad-hoc local attachment drops.
/attachments/
7 changes: 7 additions & 0 deletions .matlatlignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ user-docs/

# website/CLAUDE.md is agent guidance for the Docusaurus site, not product docs.
website/CLAUDE.md

# Studio's managed-mode working state (the controller pins the project skills
# and memory dirs here) and ad-hoc local attachment drops — machine-local
# state, not documentation. CI checkouts never contain them; ignoring them
# keeps local `check`/`index` runs byte-identical to CI's.
/.mecatl/
/attachments/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ the opt-in `provider/*` submodules (ADR 0093), and the root module all move in l
- `contracts/proto/mecatl/v1/` — gRPC contract (source of truth); `contracts/proto/mecatl/driver/v1/` — the driver protocol (SessionStoreService/MemoryStoreService stores; SkillSourceService/SoulSourceService/AgentSourceService/CommandSourceService content sources) a remote driver process implements; `contracts/gen/` is generated, **never hand-edit**.
- `cmd/mecated/` — standalone server (composition root): flags, TLS/auth/rate-limit, HTTP + metrics listeners. `cmd/mecademo/` — the offline demo. `cmd/mecatequi/` — single-shot HEADLESS composition root (peer of mecademo over `app.Build`): one prompt → a git-diff patch + a JSON Summary + an optional JSONL log + an exit code. It is **FORGE-AGNOSTIC** — knows nothing about GitHub; the glue that turns an issue into a PR lives ONLY in `.github/` + shell, NEVER the binary or `engine/`, and keeps a **split-privilege token boundary** (the agent job holds NO GitHub write token; the publish job runs NO agent code, applies the patch as DATA). See `docs/adr/0028-mecatequi.md`. `cmd/mecak8s/` — storage-free k8s-native agent (ADR 0048), a thin peer of mecated that composes `app.Build` with k8s-native defaults (Redis store + k8s lease + drain gate); no PVC, no local state — state is a managed service (Redis + k8s API server). The four real-provider mains share credential/base-URL wiring via `internal/cliconfig`.
- `cmd/mecatui/` — optional gRPC **client** TUI; by default hosts a `mecated` in-process over a UNIX socket. `ui`/`theme`/`client` import no `engine/...` or `internal/...` and no proto directly — they render from relayed proto `Event`s. See `docs/tui.md`.
- `studio/` — the web client: a Next.js **Node module**, never a Go module (not in `go.work`, the layering DAG, depguard, or api-compat). A CLIENT like mecatui, consuming only the public HTTP/SSE API through its own server-side proxies; daemon-only (an unreachable daemon renders offline, never demo data). Commands run via `task studio:*` (npm underneath). **A breaking HTTP/SSE wire change owes a Studio update in the same PR.** See ADR 0233/0234 + `studio/CLAUDE.md`.
- `perf/` — the OFFLINE scenario perf harness (perf-tracking Phase 2, `task perf:scenarios`, NOT part of `task test`): `perf/kpi` (stdlib-ONLY KPI capture — `ScenarioResult`/`Capture`/`/proc` RSS sampler; never imports `engine/...` or `internal/...`) + `perf/scenarios` (external-test `testing.B` whole-loop benchmarks over `engine/...` + `engine/adapter/*`, never `internal/...`). The TUI scrollback render bench lives in `cmd/mecatui/ui/scrollback_bench_test.go` (perf/kpi imported in the `_test` file only). `perf/cmd/perfconvert` (Phase 3; stdlib + `perf/kpi` only) reshapes the scenario JSON into the two github-action-benchmark suites the CI gate consumes, and `perf/cmd/allocsgate` (stdlib only, tested, FAIL-CLOSED) is the deterministic allocs/op gate over `task bench` — the gate DECISION (benchstat is the local human A/B tool only, never the CI gate); the gate is `.github/workflows/perf.yml` (split: allocsgate over `task bench` + github-action-benchmark over the scenarios; PR fails-but-never-pushes, main pushes the `gh-pages` trend store). See `docs/adr/0019-perf-tracking.md`.

## The layering rule (the thing to get right)
Expand Down
3 changes: 3 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ includes:
site:
taskfile: website/Taskfile.yml
dir: website
studio:
taskfile: studio/Taskfile.yml
dir: studio

vars:
PKG: github.com/stacklok/mecatl
Expand Down
105 changes: 105 additions & 0 deletions docs/adr/0233-studio-atrium-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# ADR 0233 — Studio: the Atrium workspace as mecatl's daemon-only web client

- Status: Accepted
- Date: 2026-08-18
- Scope: `studio/` — what the web client is, what it may talk to, how it deploys,
and what it deliberately does not do

> History: this decision subsumes the unmerged ADR drafts from PR #548 ("Studio
> module", numbered 0225 there before that id was taken on main) and carries its
> security posture forward under the new UI. PR #548 is superseded by the change
> that lands this ADR.

## Context

Mecatl needed a web client. Two candidates existed side by side: the original
Studio module on the unmerged `feat/studio-module` branch — a single-page chat
with a hardened server tier (origin-pinned proxies, a managed-`mecated`
supervisor, a typed wire seam, behavior tests) but a one-file UI — and the
Atrium workspace prototype in `stacklok/enterprise-ui-prototypes`, a designed
five-surface product (Chats · Scheduled · Skills · Memory · Settings) that
already spoke mecated's SSE protocol, but grew inside a ToolHive console fork:
its own OIDC stack, a mock server, generated clients for services mecatl does
not have, and per-surface demo fallbacks that rendered fabricated content
whenever the daemon was away.

Neither was shippable alone. The prototype had the product; the module had the
posture.

## Decision

**Studio is the Atrium workspace UI mounted on the original module's server
tier, in-repo at `studio/`, and it is daemon-only.**

- **A Node module, never a Go module.** Studio is a CLIENT of the daemon like
`mecatui`: it is not in `go.work`, the layering DAG, depguard, or the
api-compat gate. It consumes only the public HTTP/SSE API, through its own
server-side route handlers — the browser never holds a daemon address or
credential.
- **Two pure deployment modes.** Managed: `scripts/local-controller.mjs`
supervises a `mecated` it spawns from `../bin/mecated` on a random loopback
port with a generated bearer token. External: `MECATL_BASE_URL` selects a
remote daemon; no controller runs and every local control surface answers 409
as owned by the deployment. Nothing in between.
- **Daemon-only.** The prototype's mock server, demo fixtures, and per-hook
fallbacks are excluded at import. An unreachable daemon is a rendered state —
a shared runtime-status provider polls the daemon and controller, shows the
offline banner, and gates every surface's loads. Probe failure must never
produce fabricated content. (The managed controller may still run
`mecated --mock`: that is a real daemon with a mock LLM provider, which is
what offline development means here.)
- **One typed wire seam.** `src/lib/protocol/` is the only reader of raw daemon
JSON: structural decoders that throw on malformed frames, surface unknown
event kinds as visible notices, and encode the request/response asymmetry
(protojson requests, stdlib-JSON responses). Generated TypeScript bindings
from `contracts/proto` remain deferred; the seam plus its behavior tests is
the stopgap, and a breaking wire change owes a Studio update in the same PR.
- **The security posture is inherited, not re-derived.** Host/Origin/CSRF
pinning at the Next tier (`MECATL_STUDIO_PUBLIC_ORIGIN`); bearer injection
server-side only; controller mutations require the server-set
`x-mecatl-studio-request` header, loopback Host, and an allowlisted Origin;
bounded bodies; MCP gateway egress is HTTPS-only (loopback HTTP behind an
operator env opt-in) with the gateway URL user-entered but always validated;
the session workspace is resolved server-side (controller status or
`MECATL_WORKSPACE`) and injected into create bodies so the browser never
learns or chooses paths.
- **Deliberate non-features.** No provider-credential entry anywhere: mecated
reads keys from its auth file, and Studio's provider card only reports
status. Memory is read-only (the daemon has no write API, by design — a
hand-typed value would enter turn-0 context without injection scanning).
Skill and agent-definition authoring, learned-skill review, team runs, the
plan-approval flow, and live re-attach to running sessions (gRPC-only today)
are named follow-ups, not silent gaps.
- **Toolchain.** npm with a committed lockfile (security pins carried from the
prototype as npm `overrides`), Node 22 LTS via `.nvmrc`, Biome for
lint+format, vitest for unit/decoder tests, and a hermetic `node --test`
harness that boots the production build in external mode against a fake
recording daemon to prove the server tier's behavior. CI runs all of it plus
`npm audit --audit-level=high` and a license-header guard; installs run with
scripts disabled.

## Consequences

One product instead of two halves: the designed workspace, on the hardened
tier, with the daemon as the single source of truth.

The costs, stated plainly:

- A fresh checkout without `bin/mecated` shows an offline screen, not a demo.
The screen names the fix (`task build`, then `task studio:dev`); losing the
zero-setup demo is the price of never rendering fabricated state.
- The deferred surfaces are real feature regressions against the prototype's
dormant code (authoring flows existed there, unmounted) and stay out until
they can land controller-mediated with the same posture.
- Vendoring a designed UI brings a large dependency tree (~40 runtime
packages) into the repo's audit surface; dependabot and the audit gate own
that from here.
- The same-PR rule now binds a much larger client: a daemon wire change costs
a Studio change in the same PR, every time.

## See also

- [ADR 0234](./0234-studio-server-backed-chats.md) — the chat list is the
daemon's session store
- `docs/architecture.md` — the Studio client section
- `user-docs/what-you-get/studio.md` — what operating it looks like
96 changes: 96 additions & 0 deletions docs/adr/0234-studio-server-backed-chats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ADR 0234 — Studio's chat list is the daemon's session store

- Status: Accepted
- Date: 2026-08-18
- Scope: `studio/` — where a chat's existence, title, and transcript live, and which
client actions are server calls

> History: re-lands the unmerged draft from PR #615 (numbered 0227 there before
> that id was taken on main), adapted to the Atrium workspace UI.

## Context

The prototype Studio descended from kept its chat list in browser state — first
a `localStorage` key, then mock fixtures mirrored in module scope. `mecated`
persisted the same sessions the whole time: the daemon already exposed
`GET /v1/sessions`, `GET /v1/sessions/{id}/transcript`,
`POST /v1/sessions/{id}/rename`, and `POST /v1/sessions/{id}/delete` — and the
client called none of them.

Every consequence followed from that one gap:

- A second browser, or a second machine, saw nothing. The daemon held the work;
the client that opened it held the only record of what the work was called.
- Renaming a chat renamed a local label that no other client would ever see.
- Deleting a chat dropped the client's copy and left the session in the store
forever — "delete" quietly meant "hide".
- A reload during a run orphaned it. `mecated` keeps running after the page
that started it goes away, but the client had no way back to the result: the
run streams off the `POST /prompt` response body, and that body is gone.
- The client also minted its own session ids and mapped them lazily onto daemon
sessions, so the daemon's record and the sidebar disagreed about what even
existed.

The persistence was never missing. It was unused.

## Decision

The daemon's session store is the record of which chats exist, what each is
called, and what was said in each. Studio reads and writes that record, and
**the sidebar id IS the daemon session id** — there is no client-side session
mapping.

- **The chat list is the session inventory.** Studio walks `GET /v1/sessions`
when the daemon becomes reachable and on a slow poll, and merges the result
into its list. A row is removed only when a COMPLETE walk proves it gone —
a partial walk updates what it saw and never deletes. Rows whose one
not-a-chat reason is `inspect_only_kind` (subagents, team members, scheduled
fires) are filtered by the decoder, not by id-prefix guessing.
- **Rename is `POST …/rename`.** The local update is optimistic and rolls back
if the daemon refuses. Studio adopts the title the daemon actually stored,
which is clamped, rather than the one it asked for.
- **Delete is `POST …/delete`.** A refusal keeps the row: the chat still
exists, and hiding it locally is the exact failure this ADR is about. Only a
404 — the daemon saying it has no such session — removes a row without a
witnessed walk.
- **Opening a chat reads `GET …/transcript`.** The authoritative message-level
snapshot, which also covers scheduler-tick fires whose conversation never
reached the durable event log, and works identically in external mode. A
transcript the daemon cannot prove whole says so in the UI.
- **Eligibility is the daemon's, not Studio's.** Each inventory row carries
`capabilities` plus a closed machine-readable reason per disabled action.
Studio renders the reason; it does not re-derive who may rename or delete
what, so tightening the rule server-side needs no client change.
- **A new chat is a draft.** No daemon session is created until the first
prompt is sent; the mint happens then, and the route adopts the daemon's id.
Empty sessions never accumulate in the store from idle "new chat" clicks.

## Consequences

A chat renamed or deleted on one machine is renamed or deleted on every
machine, and survives clearing the browser. Sessions can finally be removed
from the store through the UI. A reload during a run no longer loses it: the
inventory row shows the session still running on the daemon, and the
transcript is read back when it ends.

The costs, stated plainly:

- **The list reorders on rename.** A rename advances the daemon's stored
mtime, and that mtime is the only ordering every client can agree on.
- **A rehydrated transcript is not identical to the live one.** The transcript
endpoint returns the model's conversation, which includes the harness's own
synthetic continuations as user-role messages with no provenance to
distinguish them. Fixing this needs a provenance field on the daemon's
`ConversationMessage`, not string-sniffing in the client.
- **Reattaching to a LIVE run is still not possible.** Studio can see a run in
flight and read its transcript once it ends, but it cannot resume the
stream: `GET /v1/sessions/{id}/events` is a finite replay, and the live
per-session subscription is reachable only over gRPC `StreamSessionLive`.
Closing that gap is a daemon change, not a client one.
- **Polling.** The list reconciles on an interval rather than a push, so a
change made elsewhere appears within seconds, not instantly.

## See also

- [ADR 0233](./0233-studio-atrium-module.md) — the Studio module and its
daemon-only posture
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE
- [0087 — Staged mecatui transport migration](./0087-mecatui-staged-transport-migration.md) *(superseded by 0089)*
- [0088 — Explicit daemon.yaml (listener topology config)](./0088-daemon-config-file.md)
- [0222 — mecatui: ctrl+t routes by ask type; full-screen ask-args view](./0222-mecatui-ask-args-view.md)
- [0233 — Studio: the Atrium workspace as mecatl's daemon-only web client](./0233-studio-atrium-module.md)
- [0234 — Studio's chat list is the daemon's session store](./0234-studio-server-backed-chats.md)

### Retired
- [0029 — Repo-map tree-sitter](./0029-repomap-tree-sitter.md) *(retired)*
Loading