A row's stored profile could not tell a hand-declared gateway from a
shipped provider whose models someone narrowed — both look identical from
outside the adapter — so the Models page had no way to mark the routes a
deployment added itself.
The directory entry now carries `declared`, answered by the owning adapter
against its own installed catalog, and the page renders a Custom tag from
it. Absence stays "this adapter draws no such distinction" rather than
"shipped", so a route no adapter claims is labelled neither way.
Also records the default-route work's Agent Note and the e2e evidence for
all three changes: the composer switch writing the section, and the Models
page declaring a route with its own reasoning effort.
ChatView's bottom-follow recognized only wheel gestures as reader input,
so touch panning, scrollbar dragging, and keyboard paging could not leave
the bottom of a streaming transcript. Replace the wheel listener with
device-agnostic attribution: a scroll position deviating from the
observed-top ledger of the last delivered or written scrollTop is reader
input. Adds keyboard-paging and touch-style fling e2e scenarios (red under
the old implementation) and the bilingual Agent Note triplet.
The hero Preview tag becomes a superscript mono pill riding the title's
top-right (r24, bordered, business-tertiary fill), colored by the new
--dsw-alias-label-primary-bluish alias over --dsw-static-blue-900 — the
first design-owner-approved addition under the token-sheet authority
exception recorded in the ui-theme README. The expanded sidebar trigger
now uses the native 16px settings icon instead of the 14px asset.
The create card omitted the provider-level effort the editor card offers
for the same namespace, so a route declared through 添加自定义提供方 gained
a setting the moment it was reopened for editing — one the creating user
was never shown.
Both cards now render one shared control. The field, its vocabulary, and
the inherit-means-absent rule live with the control rather than in the
editor, which is what stops the two from drifting apart again.
Six review findings on the select surface, all reachable from the wire:
**Resume read the header, not the log.** The switch was recorded as
`agent-preset/selected` and every projection resolved from it, but `agentFor`
still composed from `inspected.meta.agentPreset` — the value written once at
creation. A blank session that switched and then ran turns came back after a
restart under the ORIGINAL preset, restoring that history under the tool set it
was not produced with, which is the mismatch this feature exists to prevent.
`inspected` already carries the events.
**Cold summaries dropped the preset entirely.** `summarizeCold` hand-copied
three header fields and omitted the fourth, so a restored session reported no
preset and the picker showed the deployment default. It now uses the same
projection the attached path does.
**`select` had no gate.** Two concurrent selects both passed the blank check;
the second `unmountPresetFor` then found no record, because the first had
already removed it, and both mounts installed into one agent layer. Selects on
one session now queue, and the blank check is re-read inside the queue. This is
not turn admission — a `session.prompt` racing a switch is the agent loop's to
reserve — but it closes the select-versus-select tear-down.
**A same-id restore was skipped.** The roster is a live directory, so "the same
inputs that worked a moment ago" does not hold: a changed file is exactly how a
same-id reselect fails, and skipping the restore left the agent with no
composition at all.
**`writable` was dead state**, initialized true and never set, so the row could
never disable. It now carries `settings.describe`'s bit — a browser that may
not write settings sees the current default and no control, rather than one
whose write answers `settings-not-exposed`.
**`list` was documented as id-ordered.** It is root-precedence order with each
root's own presets sorted, first root to supply an id winning.
Copying is already its own action, offered on the row being copied, so the two
routes now arrive at the same editor by whichever one the author chose. **New
preset** starting from some preset nobody named put a composition in the editor
that the author had to recognise as unwanted before deleting it — and the
preset it copied was the deployment default, which is the one least likely to
be what someone reaching for "new" wanted.
A blank draft has no source, so it names none and makes no read: the editor's
"copied from" line and header clause appear only when there is a source.
Authoring writes a FILE, not a settings field, so nothing on the wire announces
it — `settings/changed` covers the default moving, never the directory. The
chip therefore kept the roster it read when it first mounted, and a preset
authored to be used was missing from the one screen that starts sessions.
The page that changes the directory now says so, and every surface reading the
same roster re-reads it. The chip subscribes rather than being reached from the
outer scope, because it is registered later, under `conversation`/`sessions`.
The General row and the preset page already re-read on `settings/changed`, but
the hero chip did not — so changing the default and starting a session without
reloading composed the previous default. That is the one session the setting
claims to govern ("对此后新建的会话生效"), and the chip is what decides it.
A staged pick still wins: `load()` prefers the stage over the refreshed
fallback, so a refresh never overwrites a choice the user just made.
The preference row rendered `option.id` while every other surface — the
new-session chip, the session header label, the preset cards — renders the
metadata name. So the same roster read `标准模式` on one screen and `standard`
on the next, and the id is addressing, not a label. A preset that names itself
nothing still falls back to its id, which is then all there is to say about it.
The row's Chinese copy also still ended on the English word: the section is
`Agent 预设`, so the sentence about a running session keeping its composition
says 预设 too.
`ui-question`'s node half called `ctx.tools.register` on the host context.
`ScopedLayers.merge()` combines the global layer with the agent's exact-scope
layer, and an unscoped registration lands in the global one — so the tool
reached every agent no matter which preset composed it. `core-web`, sold as a
two-tool benchmark surface, really presented three.
Rendering a question is a host UI capability; having the tool is an agent
capability, and only a preset decides that. The node half is now empty and the
`tool-ask-user` row moved into the preset that wants it. The TUI keeps its own
row, having no presets.
The composition tests now assert the global tool layer is EMPTY, which is the
invariant that would have caught this: any tool outside a preset reaches every
agent. The browser lane's composition, seeded-history, and hermetic-skill
assertions address their registries through a composed agent for the same
reason — those services are per session now, and the host cannot resolve an
`isolate` realm by name.
The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.
`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.
`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.
The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.
Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.
Goldens re-recorded for the hero's preset chip and the settings nav entry.
Sections differ by hundreds of pixels — a short preferences list against
the composition editor — and the 600px panel served both badly: the
list left a wide empty band, the editor cramped a YAML file into a
16-row box with dead space under it.
The panel is now `min(800px, 100vh - 48px)`: one height for every
section, whatever does not fit scrolls where it already did. The
composition editor takes the column's full height rather than a fixed
row count, so the extra space reaches the text instead of the gap.
Refresh the settings goldens the preset section's nav entry had made
stale.
The composer seat spent nearly all its life disabled: a session's
composition is fixed once a turn has run. Move the choice to the
new-session screen beside the workspace picker, where it still works,
and let the session header report what a running session runs.
The hero pick is staged rather than applied — that screen precedes the
session it belongs to. It lands when a session becomes current and is
still blank, which covers both the session a workspace connect creates
and the blank one it reuses; riding `sessions.create` would miss the
second. It is spent on first use, matching the workspace picker.
Fix the durability the header field claimed but never had: `agentPreset`
was declared on `SessionHeader` and dropped by the JSONL header line, the
SQLite sessions row, the derived query index, and the cold list
projection, so every resumed session came back composed from nothing.
Add the web e2e lane that would have caught it — the one lane that mounts
the shipped roster, which needed `cordis:group` in the scaffold's Loader
builtins, as `mountRootInclude` already registers.
A shipped preset has nothing to commit or abandon, and the back link above
already leaves the screen; a lone Close button below the composition was a
second way out of the same place. The action row now renders only when the
draft is writable, where Cancel and Save are both real choices.
The composition label named `cordis.yml`; the file a preset actually holds
is `agent.cordis.yml`.
The picker listed presets alphabetically by id, so the shipped set read
cordis, minimal, standard — reverse order of capability. A preset may now
declare `order` in its metadata; the shipped three declare 1/2/3 and read
standard, minimal, cordis. A preset that declares none sorts behind those
that do, then by id, so authored presets stay stable.
Editing had nowhere good to live. Inside a card it was squeezed into a
~268px column; hanging off the end of the grid it was orphaned from the card
it edits. It now replaces the list: a back link, what is being edited, and
the form at full width. One thing on screen at a time, which is what the
form's height wanted all along.
Cards in different grid rows sized independently, so a short description made
a short card. `grid-auto-rows: 1fr` makes every row the same height.
The trust badge lost its pill when the card CSS was rewritten, and `In use`
never had one; both are tags now. Icon labels moved from `title` to a drawn
tooltip — the native one waits about a second, which reads as nothing
happening.
Four changes to the Agent 预设 section, all from the same reading: choosing
a preset is the common act and everything else is occasional.
Picking is now clicking the card rather than hunting a small "Set as
default" button. The card body is the button; the action row sits outside it
because nesting buttons is invalid and those actions operate ON a card
rather than select it. The body's accessible name is set explicitly —
otherwise it is the whole card read aloud, title through id.
Actions are icon-only with their label on hover and on `aria-label`, so four
controls stop competing with the content for attention.
Built-in and custom presets are separated under headings, and the trust
badge moved up beside the title where it qualifies the name. A group with no
members renders no heading.
`Local` reads as a location; these are the user's own, so the badge and the
heading both say `Custom` / 自定义.
`core-web` said neither of the things that matter about it. The `-web`
suffix is a leftover from the whole-process `core-web.cordis.yml` overlay,
and presets are per-session and not web-specific. `core` reads as "the
foundational one" when it is in fact the one with the fewest capabilities.
`minimal` says what it is and orders the shipped set legibly by capability:
minimal, standard, cordis.
Breaking: a session created under `core-web` records that id in its header
and will fail to resolve it on resume. Nothing outside this repository has
shipped, so no migration path is offered.
The identically named `config/core-web.cordis.yml` — the legacy
whole-process overlay behind the web snapshot test — is a different thing
and keeps its name.
A picker showed directory names, so the settings page could only ever list
`standard` / `core-web` / `cordis` and hope the reader knew what they meant.
A preset may now publish display text in an optional `preset.yml` beside
its composition, and the section renders cards — name, description, and the
one in use — instead of rows.
The file carries display text ONLY. `id` is the directory name and `trust`
comes from the root a preset was discovered under, so neither is writable
there: otherwise a locally authored preset could name itself into the
shipped set. It is a separate file because a composition is a top-level list
of plugin rows — YAML cannot carry sibling keys beside it, and a fake
metadata row would hand the Loader something to load.
Every read failure degrades to no metadata; absent, malformed, wrongly
typed, and blank all mean the same thing and the picker falls back to the
id. Presentation is not capability: a preset whose name is broken still
mounts.
The editor gained name and description fields above the YAML, and clearing
both removes the file rather than storing a blank name.