Merge remote-tracking branch 'origin/master' into worktree/schedule-conversational-after

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
Tianyi Cui
2026-08-11 19:54:39 +08:00
246 changed files with 3188 additions and 1538 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810
README.zh.md: 239987e379aaabd2edbfc9e09cd2fc8cef7a685e
README.md: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8
README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
## Model Experience
None, as the settings shell serves browser UI composition; nothing here reaches a model request.
None, as the settings domain base serves browser preference storage and slot declarations; nothing here reaches a model request.
#### KV Cache effect
@@ -16,4 +16,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general.
- **Remote browsers get no durable settings** — the settings RPCs are loopback-only, so a scope bound in a non-loopback browser starts `unavailable` and never crosses the wire; every row it backs is inert there.
- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`并声明由注册方填充的 slot`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面。外壳不自带文案所有文本都来自注册方ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)
设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general因为外壳一旦依赖 ui-sidebar就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起
外壳将首次使用引导记录按升序投影每次只挂载一个页面接管界面框架body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源
该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值
## 模型体验
无。设置外壳为浏览器 UI 提供组合能力;这里没有任何内容进入模型请求。
无。设置领域底座为浏览器提供偏好设置存储与 slot 声明;这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -16,4 +16,5 @@
## 已知限制与暂缓事项
- **面板仅涵盖浏览器偏好设置**:宿主侧设置表层(权限模式、工具调用模式)尚无 RPC 支撑;其骨架位于 ui-settings-general。
- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效的。
- **每次写入仅一个字段**`set` 只发送单个 `set` op因此需要同时改动两个字段的行没有事务可用会发布两个 revision。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
"description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
@@ -32,8 +32,9 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar"
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -43,30 +44,31 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-settings": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -1,224 +0,0 @@
/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
foot trigger row + centered 1080x700 modal panel. The trigger reproduces
the former sidebar foot geometry (49px wide row / 36px rail circle); the
panel is a two-column layout — 188px nav rail + content column with a
54px header and the 24px-padded options area. */
/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */
.trigger {
flex: none;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 49px;
margin: 8px 0 0;
padding: 0 2px 0 6px;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-family: inherit;
font-size: 14px;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Rail trigger: the same 36x36 circle box as the other rail controls. */
.trigger.rail {
width: 36px;
height: 36px;
margin: 18px 0 10px;
justify-content: center;
gap: 0;
padding: 0;
border-radius: 50%;
}
.triggerLabel {
overflow: hidden;
white-space: nowrap;
}
/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800 wide.
One height for every section, taken from the viewport rather than the
content: sections differ by hundreds of pixels (a settings list against the
composition editor), and a content-sized panel would resize under the
pointer on every nav click. Whatever does not fit scrolls in `.options`. */
.panel {
position: relative;
z-index: 1;
display: flex;
width: 800px;
height: min(800px, calc(100vh - 48px));
max-width: calc(100vw - 48px);
border-radius: 24px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
Declared on the panel rather than the scrolling `.options` child so the
elevation choice sits with the surface; the custom properties inherit
down to whichever descendant scrolls (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0),
gap 18, no own fill — the panel white shows through. */
.nav {
flex: none;
display: flex;
flex-direction: column;
gap: 18px;
width: 188px;
padding: 22px 12px 0;
box-sizing: border-box;
}
/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */
.navTitle {
padding: 0 12px;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Cell stack (figma 501:29961): gap 4. */
.navList {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad
(12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */
.navCell {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 9px 16px 9px 12px;
box-sizing: border-box;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 14px;
line-height: 22px;
font-weight: 400;
color: var(--dsw-alias-label-primary);
text-align: left;
}
.navCell:hover {
background: var(--dsw-specific-sidebar-nav-item-hover);
}
.navCell.active {
background: var(--dsw-specific-sidebar-nav-item-active);
}
.navIcon {
flex: none;
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Content column (figma Content 501:29980): header + options. */
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */
.header {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
height: 54px;
padding: 20px 14px 8px 10px;
box-sizing: border-box;
}
.actions {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 28px;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
.options {
flex: 1;
min-height: 0;
padding: 0 24px 24px;
overflow-y: auto;
}
/* Visually-hidden text seat (close button accessible name from slot content). */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,172 +0,0 @@
/**
* Settings shell root: the sidebar-foot trigger row plus the centered modal
* panel (figma 501:29947, 1080x700) with the section nav rail. The shell is
* a pure composition face — every piece of text (trigger label, panel title,
* close label, sections) arrives from registrants through slots; accessible
* names resolve to that content (trigger: its own text; dialog:
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state;
* the onboarding coordinator mounts exactly one ordered registrant while the
* sessions-derived empty-Hero fact is active — the takeover chrome
* (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
* paints nothing here.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
type PanelProps = {
rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot']
activeId: string | undefined
onSelect: (id: string) => void
onClose: () => void
}
/**
* The modal layer: full-viewport mask + centered panel. Close paths: the
* header button, a mask click, and document-level Escape (mounted only while
* open, so the listener lifetime is the panel's).
*/
function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelProps) {
// Entries can unmount underneath the requested id, so the render-time
// projection falls back to the first row when the id is gone.
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [onClose])
// Baseline focus management: entering the dialog lands on the close button.
const closeButton = useRef<HTMLButtonElement | null>(null)
useEffect(() => { closeButton.current?.focus() }, [])
return (
<div className={css.overlay} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
<nav className={css.nav}>
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
<div className={css.navList}>
{rows.map(row => (
<button
key={row.id}
type="button"
className={clsx(css.navCell, row.id === active && css.active)}
aria-current={row.id === active ? 'true' : undefined}
onClick={() => { onSelect(row.id) }}
>
{navIcon(row.id)}
<span className={css.navLabel}>{row.label}</span>
</button>
))}
</div>
</nav>
<div className={css.content}>
<div className={css.header}>
<div className={css.actions}>{renderSlot('settings.action', {})}</div>
<button ref={closeButton} type="button" className={css.close} onClick={onClose}>
<IconCloseOutline16 size={14} />
<span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
</button>
</div>
<div className={css.options}>
{active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
</div>
</div>
</div>
</div>
)
}
/**
* Render the settings trigger and panel.
* @param props - composed slot props (contract/slots.ts).
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
const [open, setOpen] = useState(false)
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const close = useCallback(() => {
setOpen(false)
setActiveId(undefined)
}, [])
const openSection = useCallback((id: string) => {
setActiveId(id)
setOpen(true)
}, [])
// The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
const rows = useSections(s => s)
const onboardingSteps = useOnboardingSteps(s => s)
const onboardingActive = useSessions(state =>
state.phase === 'ready'
&& (state.current === undefined || state.byId[state.current]?.blank === true))
const onboardingStep = onboardingActive
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
: undefined
useEffect(() => {
if (onboardingActive) return
setCompletedOnboarding(new Set())
}, [onboardingActive])
const completeOnboardingStep = useCallback((id: string) => {
setCompletedOnboarding((previous) => {
if (previous.has(id)) return previous
return new Set([...previous, id])
})
}, [])
return (
<>
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(true) }}
>
{renderSlot('settings.trigger', { wide })}
</button>
{open && (
<SettingsPanel
rows={rows}
renderSlot={renderSlot}
activeId={activeId}
onSelect={setActiveId}
onClose={close}
/>
)}
{/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
inert) lives inside the step component, wrapped around its visible
content — a step still deciding (private facts loading) renders
null, so nothing paints or blocks while it decides. */}
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</>
)
}

View File

@@ -1,16 +1,14 @@
/**
* Settings shell slot contract — the canonical home of every settings slot
* type. The shell is a pure composition face with zero copy of its own: it
* occupies the sidebar-owned `sidebar.settings` hole and declares the slots
* below; ALL text (trigger label, panel title, header actions, close aria,
* section content) arrives from registrants. A feature owns its settings surface — adding a
* setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general.
* Settings slot contract — the canonical home of every settings slot type,
* owned by the settings domain base rather than by the shell that renders
* them (ui-settings-general, which occupies `sidebar.settings`). The shell has
* zero copy of its own: ALL text (trigger label, panel title, header actions,
* close aria, section content) arrives from registrants. A feature owns its
* own settings pages — adding a setting never means editing the shell; copy
* that belongs to no single feature (chrome, the General section) is owned by
* ui-settings-general too.
*/
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -66,8 +64,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* would render without mask or stage).
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
/**
* One preference row inside the General section, contributed by the
* feature plugin that owns the preference (locale → Language, ui-theme →
* Appearance, ui-conversation → Composer Enter). Options: `id` (row key),
* `order` (row position). Rows draw their own internals; the section
* column only stacks them. Declared at runtime by ui-settings-general's
* General entry — the type lives here with every other settings slot type,
* because this package is the settings domain's base layer and every
* registrant already depends on it for `ctx.settingsScope`.
*/
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}
/** Owner share of the trigger content seat: the sidebar column state. */
export interface SettingsTriggerOwnerProps {
@@ -102,48 +116,3 @@ export interface SettingsOnboardingOwnerProps {
/** Open the settings panel directly on one registered section. */
openSection: (id: string) => void
}
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/** One ordered onboarding step projected from a slot registration. */
export interface SettingsOnboardingStep {
id: string
order: number
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<
| 'settings.trigger'
| 'settings.header'
| 'settings.action'
| 'settings.close'
| 'settings.section'
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>

View File

@@ -1,111 +1,35 @@
/**
* Settings shell plugin, browser half. A pure composition face: occupies the
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
* panel, declares its chrome, section, and onboarding slots, and projects the
* section ledger into panel navigation. The shell ships no copy; it reads the
* optional locale revision only to resolve registrant-owned nav-label thunks.
* ui-settings-general owns the chrome and General content; features own their
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
* Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the
* settings-namespace Host transport every preference row binds its durable
* section through, and owns the canonical slot-type contract for the settings
* surface. It depends on no `ui-*` presentation package, so any feature that
* owns a preference can reach it: the settings SHELL — the `sidebar.settings`
* occupant, its navigation, and the chrome — lives in ui-settings-general,
* because a shell dependency on ui-sidebar would close a reference cycle
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
// read (nav labels may be locale-following thunks; the shell still ships no
// copy of its own and takes no hard locale dependency).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { SettingsScopeService } from './settings-scope.ts'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
SettingsSectionRow, SettingsTriggerOwnerProps,
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-sidebar's apply, whose activation order relative to this one is NOT
* constrained (dsh.client.inject edges are informational); registration
* depends on the slot through `slots.inject()`.
* Required services: none. The transport is resolved per caller through
* `this.ctx` at `bind` time, so this plugin waits for nothing.
*/
export const inject = ['slots']
export const inject = []
/**
* Register the settings shell into `sidebar.settings` once the declaration is
* on the ledger.
* Provide the settings-namespace scope service.
*
* Constructing the service in this plugin's fiber keeps its traced methods
* bound to each consuming plugin's context.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Ledger → nav-row projection as an observable source (uSES contract:
// getSnapshot returns the cached rows until the ledger version moves).
// Labels may be locale-following thunks, so the cache key includes the
// locale revision and subscribers ride both sources.
let rowsVersion = -1
let rowsRevision = -1
let rows: readonly SettingsSectionRow[] = []
let onboardingVersion = -1
let onboardingSteps: readonly SettingsOnboardingStep[] = []
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
const injected = (): SettingsRootInjected => ({
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
const revision = localeRevision()
if (version !== rowsVersion || revision !== rowsRevision) {
rowsVersion = version
rowsRevision = revision
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: resolveSlotLabel(e.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.section', listener)
const offLocale = ctx.get('locale')?.subscribe(listener)
return () => {
offLedger()
offLocale?.()
}
},
},
onboardingSteps: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.onboarding')
if (version !== onboardingVersion) {
onboardingVersion = version
onboardingSteps = ctx.slots.entries('settings.onboarding')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id */
id: e.options.id ?? '',
order: e.options.order ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
return onboardingSteps
},
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
},
},
})
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: injected,
}, SettingsRoot))
new SettingsScopeService(ctx)
}

View File

@@ -0,0 +1,252 @@
/**
* Host transport for the settings-namespace scope contract. The contract types
* live in `dsh-client-runtime` (the common dependency of every feature that
* owns a preference); this file owns the wire behavior and the invalidation
* subscription, both of which are Settings-surface concerns.
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import {
createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot,
type SettingsScopeSpec, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this
// package is reachable from the Host build graph through its feature-package
// callers, and api-remotes' Client face imports a Host-tsdown-generated
// `/remote` artifact, which would deadlock the Host tsc phase. The gateway's
// Client half declares `ctx.remote` with no generated import, and the
// allowlist's `types` subpath is a pure-type source file, so the pair supplies
// `$on` and its key face without dragging a build artifact in. The runtime
// `remote` injection belongs to whoever calls bindSettingsScope: the
// subscription is registered on the caller's own context.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type {} from '@deepseek-ai/dsh-api-remotes/types'
// The forwarded event's own declaration: `$on`'s key face is
// `Extract<keyof Events, keyof Selection>`, so the allowlist alone resolves to
// never — the owning package's client-safe, type-only subpath supplies the
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
import type {} from '@deepseek-ai/dsh-settings/types'
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one namespace's Host reads and writes behind a snapshot store.
* Reads never block plugin activation; writes carry the latest known
* namespace revision and teardown waits for the operation already crossing
* the wire.
*/
export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
revision: undefined,
writable: false,
mode: persistence,
})
}
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T> {
return this.store.getSnapshot()
}
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void {
return this.store.subscribe(listener)
}
/**
* Queue a Host refresh; a newer read or user write suppresses stale publication.
* @returns settlement after the queued read completes or is skipped.
*/
load(): Promise<void> {
const generation = ++this.readGeneration
return this.enqueue(() => this.read(generation))
}
/**
* Queue one field write; see {@link SettingsScope.set} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
const revision = this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
} catch (_settingsWriteFailure) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
if (!response.result.ok) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
this.accept(response.result.value, generation === this.writeGeneration)
})
}
/**
* Stop queued operations and wait for the current wire call to settle.
* @returns settlement after the controller reaches quiescence.
*/
async dispose(): Promise<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const { namespaces, writable } = response.result.value
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
const publish = generation === this.readGeneration
if (view === undefined) {
if (publish) {
this.store.update((draft) => {
draft.status = 'unavailable'
draft.writable = writable
})
}
return
}
this.accept(view, publish, writable)
}
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'
draft.value = decoded
})
}
private decode(view: SettingsNamespaceView): T | undefined {
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
// Sections are plain objects by construction; schemastery alone would
// resolve null or an array through object defaults instead of refusing.
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
return undefined
}
return failure === undefined ? view.value as T : undefined
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
settingsScope: SettingsScopeService
}
}
/**
* The settings domain's base service. Features that own a preference reach the
* settings transport through this service rather than a shared function: the
* client bundle purity gate forbids cross-plugin value imports and directs
* cross-plugin collaboration through cordis services
* (`packages/client/tsdown.client.ts`).
*/
export class SettingsScopeService extends Service {
/**
* @param ctx - the providing plugin's context.
*/
constructor(ctx: Context) {
super(ctx, 'settingsScope')
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the
* caller at call time, so the scope's disposer belongs to the calling fiber.
* Listeners exist before the initial background read starts, so activation
* never blocks on the settings transport. The caller injects `connection`
* for the transport and `remote` for the forwarded settings invalidation.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T> {
const ctx = this.ctx
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
(ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `ui-settings: ${spec.namespace} settings scope`)
return controller
}
}

View File

@@ -1,4 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the settings shell plugin. */
/** Host plugin body — no host-side behavior for the settings domain base plugin. */
export function apply(): void {}

View File

@@ -1,139 +0,0 @@
/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
return { ctx, slots: ctx.get('slots') as SlotsService }
}
function declare(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
function injectedOf(slots: SlotsService): SettingsRootInjected {
const entry = slots.entries('sidebar.settings')[0]!
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
const CHILD_SPECS = {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
} as const
describe('ui-settings apply', () => {
it('declares only the slot registry (a pure composition face, no locale)', () => {
expect(inject).toEqual(['slots'])
})
it('registers the shell and declares every child slot, before or after the declaration', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
})
it('projects the section ledger into ordered nav rows with option defaults', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger.
expect(sections.getSnapshot()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
const rows = sections.getSnapshot()
expect(rows).toEqual([
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off()
})
it('projects onboarding entries into stable coordinator order', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { onboardingSteps } = injectedOf(b.slots).hooks
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
const steps = onboardingSteps.getSnapshot()
expect(steps).toEqual([
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
{ id: 'default-order', order: 0 },
])
expect(onboardingSteps.getSnapshot()).toBe(steps)
const listener = vi.fn()
const off = onboardingSteps.subscribe(listener)
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
off()
})
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
// Declarer unload: the cascade removes our entry and every child
// declaration while our local disposer variable goes stale.
redeclare()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
expect(b.slots.spec('settings.trigger')).toBeUndefined()
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
})
it('unregisters the shell and collapses every child slot on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toBeUndefined()
}
})
})

View File

@@ -0,0 +1,29 @@
/**
* The settings domain base plugin's own mounting behavior: it stands up
* `ctx.settingsScope` for every feature that owns a preference row, and the
* service retires with its fiber.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { apply, inject, SettingsScopeService } from '../src/client/index.ts'
/** Boot the browser half over a bare root context; it injects nothing. */
function bench() {
const ctx = new Context()
return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) }
}
describe('settings domain base plugin', () => {
it('mounts the scope service under settingsScope', async () => {
const { ctx, fiber } = bench()
await fiber.await()
expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeService)
})
it('fiber disposal retires the service', async () => {
const { ctx, fiber } = bench()
await fiber.await()
await fiber.dispose()
expect(ctx.get('settingsScope')).toBeUndefined()
})
})

View File

@@ -1,247 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
afterEach(cleanup)
type Row = { id: string; order: number; label: string }
type Step = { id: string; order: number }
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
const SEAT_CONTENT: Record<string, string> = {
'settings.trigger': 'Settings',
'settings.header': 'Settings Title',
'settings.action': 'Open configuration file',
'settings.close': 'Close',
}
function mount({
wide = true,
onboardingActive = true,
rows = [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
],
steps = [
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
],
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => {
if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
return SEAT_CONTENT[key]
}) as SettingsRootComponentProps['renderSlot'],
)
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
? { phase: 'ready', current: undefined, byId: {} }
: {
phase: 'ready',
current: 'active-session',
byId: { 'active-session': { blank: false } },
})) as never
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
const props: SettingsRootComponentProps = {
useSessions,
useWorkspaces: unusedHook,
wide,
useOnboardingSteps: select => select(steps),
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
},
renderSlot,
}
const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => {
act(() => {
current = next
for (const fn of [...listeners]) fn()
})
}
return { view, renderSlot, bump, listeners }
}
function openPanel() {
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
}
describe('SettingsRoot trigger', () => {
it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
const { renderSlot } = mount()
const trigger = screen.getByRole('button', { name: 'Settings' })
expect(trigger.hasAttribute('aria-label')).toBe(false)
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
})
it('hands the rail state to the trigger seat', () => {
const { renderSlot } = mount({ wide: false })
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
})
})
describe('SettingsPanel chrome seats', () => {
it('names the dialog via aria-labelledby pointing at the header seat node', () => {
mount()
openPanel()
const dialog = screen.getByRole('dialog')
const titleId = dialog.getAttribute('aria-labelledby')!
expect(titleId).toBeTruthy()
const title = document.getElementById(titleId)!
expect(title.textContent).toBe('Settings Title')
expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
})
it('names the close button through the visually-hidden close seat text', () => {
mount()
openPanel()
const close = screen.getByRole('button', { name: 'Close' })
expect(close.hasAttribute('aria-label')).toBe(false)
expect(close.textContent).toContain('Close')
})
it('renders header actions before the shell-owned close control', () => {
const { renderSlot } = mount()
openPanel()
expect(screen.getByText('Open configuration file')).toBeTruthy()
expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
})
})
describe('SettingsPanel close paths', () => {
it('closes via the header button', () => {
mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('closes via a mask click', () => {
mount()
openPanel()
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog.parentElement!.firstElementChild!)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('closes via document-level Escape and unhooks the listener with the panel', () => {
mount()
openPanel()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
// Ignored while closed (listener removed with the panel) and non-Escape
// keys are ignored while open.
fireEvent.keyDown(document, { key: 'Escape' })
openPanel()
fireEvent.keyDown(document, { key: 'Enter' })
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('lands focus on the close button when the dialog opens', () => {
mount()
openPanel()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
})
})
describe('SettingsPanel navigation', () => {
it('projects rows, marks the first active, and renders only that section', () => {
mount()
openPanel()
expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
expect(screen.getByTestId('section-general')).toBeTruthy()
})
it('switches the rendered section on nav click', () => {
mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
expect(screen.getByTestId('section-models')).toBeTruthy()
expect(screen.queryByTestId('section-general')).toBeNull()
})
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
const { renderSlot } = mount()
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
expect(first?.[2]).toEqual({ only: 'welcome' })
act(() => {
(first?.[1] as { complete: () => void }).complete()
;(first?.[1] as { complete: () => void }).complete()
})
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
const second = onboardingCalls.at(-1)
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
expect(second?.[2]).toEqual({ only: 'credential' })
act(() => {
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
})
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByTestId('section-models')).toBeTruthy()
cleanup()
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
.filter(call => call[0] === 'settings.onboarding')
expect(inactive).toHaveLength(0)
})
it('paints no takeover chrome of its own around the mounted step', () => {
// The chrome (mask, opaque stage, #root inert) belongs to the step via
// the OnboardingSurface primitive — a mounted-but-deciding step that
// renders null must show and block nothing (the reload white-flash fix;
// onboarding-surface.spec.tsx pins the primitive's half).
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const { view } = mount()
expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
expect(appRoot.inert).not.toBe(true)
view.unmount()
appRoot.remove()
})
it('falls back to the first row when the active entry unregisters', () => {
const { bump } = mount()
openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
bump([{ id: 'general', order: 0, label: 'General' }])
expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
expect(screen.getByTestId('section-general')).toBeTruthy()
})
it('renders an empty content column when the ledger is empty', () => {
const { renderSlot } = mount({ rows: [] })
openPanel()
expect(screen.getByRole('dialog')).toBeTruthy()
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
expect(sectionCalls).toHaveLength(0)
})
it('drops the ledger subscription on unmount', () => {
const { view, listeners } = mount()
expect(listeners.size).toBe(1)
view.unmount()
expect(listeners.size).toBe(0)
})
})

View File

@@ -0,0 +1,355 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
}
const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: ENVELOPE,
value,
applies: 'live',
secrets: [],
revision,
}
}
function described(value: unknown, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Record each distinct published section, starting from the current one. */
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
scope.subscribe(() => {
const value = scope.getSnapshot().value
if (value !== seen[seen.length - 1]) seen.push(value)
})
return seen
}
describe('SettingsScopeController', () => {
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
expect(scope.getSnapshot()).toEqual({
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
})
await scope.load()
expect(scope.getSnapshot()).toEqual({
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
})
})
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
.mockResolvedValueOnce(described(null, 5))
.mockResolvedValueOnce(described('scalar', 6))
.mockResolvedValueOnce(described(['queue'], 7))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const good = trackValues(scope)
for (let i = 0; i < 7; i++) await scope.load()
expect(scope.getSnapshot()).toMatchObject({
status: 'ready', value: { preference: 'dark' }, revision: 7,
})
expect(good).toEqual([undefined, { preference: 'dark' }])
})
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const statuses: string[] = []
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
const stale = scope.load()
const fresh = scope.load()
await Promise.all([stale, fresh])
expect(statuses).not.toContain('unavailable')
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
})
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().status).toBe('ready')
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
})
it('applies a custom decode override in place of the wire schema', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{
namespace: 'ui-test',
decode: section => (section as UiTestSettings).preference === 'dark'
? section as UiTestSettings
: undefined,
},
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.load()
const dark = scope.set('preference', 'dark')
const light = scope.set('preference', 'light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
await Promise.all([dark, light])
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
expect(mutate).toHaveBeenNthCalledWith(1, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 5,
})
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.set('preference', 'dark')
await scope.set('preference', 'system')
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await Promise.all([
scope.set('preference', 'dark'),
scope.set('preference', 'system'),
scope.set('preference', 'light'),
])
expect(describeCall).not.toHaveBeenCalled()
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
})
it('keeps the write queue usable when a subscriber throws', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
let thrown = false
scope.subscribe(() => {
if (thrown) return
thrown = true
throw new Error('subscriber failed')
})
await expect(scope.load()).rejects.toThrow('subscriber failed')
await expect(scope.load()).resolves.toBeUndefined()
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const describeCall = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
const dark = scope.set('preference', 'dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = scope.set('preference', 'light')
let stopped = false
const stop = scope.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
first.resolve(ok(view({ preference: 'dark' }, 1)))
await Promise.all([dark, light, stop])
await scope.set('preference', 'system')
await scope.load()
expect(mutate).toHaveBeenCalledOnce()
expect(describeCall).not.toHaveBeenCalled()
expect(published).toEqual([undefined])
})
it('keeps a remote browser in memory mode without Host calls', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
'memory',
)
expect(scope.getSnapshot()).toEqual({
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
})
await scope.load()
await scope.set('preference', 'dark')
await scope.dispose()
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('SettingsScopeService.bind', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory mode without starting a settings read', async () => {
const describeCall = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
const fiber = ctx.plugin({
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
},
})
await fiber.await()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
await fiber.dispose()
expect(describeCall).not.toHaveBeenCalled()
})
})

View File

@@ -11,20 +11,23 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
},
{
"path": "../runtime"
},
{
"path": "../ui-sidebar"
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../api/gateway"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"